claude-plugin-wordpress-manager 2.12.2 → 2.13.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.
Files changed (29) hide show
  1. package/.claude-plugin/plugin.json +8 -3
  2. package/CHANGELOG.md +55 -0
  3. package/docs/GUIDE.md +240 -1
  4. package/docs/VALIDATION.md +341 -0
  5. package/docs/plans/2026-03-02-content-framework-architecture.md +612 -0
  6. package/docs/plans/2026-03-02-content-framework-strategic-reflections.md +228 -0
  7. package/docs/plans/2026-03-02-content-intelligence-phase2.md +560 -0
  8. package/docs/plans/2026-03-02-content-pipeline-phase1.md +456 -0
  9. package/docs/plans/2026-03-02-editorial-calendar-phase3.md +490 -0
  10. package/docs/validation/.gitkeep +0 -0
  11. package/docs/validation/dashboard.html +286 -0
  12. package/docs/validation/results.json +1705 -0
  13. package/package.json +12 -3
  14. package/scripts/run-validation.mjs +1132 -0
  15. package/servers/wp-rest-bridge/build/server.js +16 -5
  16. package/servers/wp-rest-bridge/build/tools/index.js +0 -9
  17. package/servers/wp-rest-bridge/build/tools/plugin-repository.js +23 -31
  18. package/servers/wp-rest-bridge/build/tools/schema.js +10 -2
  19. package/servers/wp-rest-bridge/build/tools/unified-content.js +10 -2
  20. package/servers/wp-rest-bridge/build/wordpress.d.ts +0 -3
  21. package/servers/wp-rest-bridge/build/wordpress.js +16 -98
  22. package/servers/wp-rest-bridge/package.json +1 -0
  23. package/skills/wp-analytics/SKILL.md +153 -0
  24. package/skills/wp-analytics/references/signals-feed-schema.md +417 -0
  25. package/skills/wp-content-pipeline/SKILL.md +461 -0
  26. package/skills/wp-content-pipeline/references/content-brief-schema.md +377 -0
  27. package/skills/wp-content-pipeline/references/site-config-schema.md +431 -0
  28. package/skills/wp-editorial-planner/SKILL.md +262 -0
  29. package/skills/wp-editorial-planner/references/editorial-schema.md +268 -0
@@ -0,0 +1,1132 @@
1
+ #!/usr/bin/env node
2
+ // scripts/run-validation.mjs — MCP Tool Validation Runner
3
+ // Spawns wp-rest-bridge server, connects via MCP SDK, tests all registered tools.
4
+ // Usage:
5
+ // node scripts/run-validation.mjs # interactive mode
6
+ // node scripts/run-validation.mjs --site=opencactus # target specific site
7
+ // node scripts/run-validation.mjs --module=gsc # single module
8
+ // node scripts/run-validation.mjs --include-writes # include write tools
9
+ // node scripts/run-validation.mjs --test-writes # CRUD sequence testing
10
+ // node scripts/run-validation.mjs --test-writes --tier=1 # only Tier 1
11
+ // node scripts/run-validation.mjs --delay=200 # ms between calls
12
+
13
+ import { createRequire } from 'module';
14
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
15
+ import { resolve, dirname } from 'path';
16
+ import { fileURLToPath } from 'url';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const PROJECT_ROOT = resolve(__dirname, '..');
20
+ const SERVER_DIR = resolve(PROJECT_ROOT, 'servers/wp-rest-bridge');
21
+ const SDK_PATH = resolve(SERVER_DIR, 'node_modules/@modelcontextprotocol/sdk');
22
+ const RESULTS_PATH = resolve(PROJECT_ROOT, 'docs/validation/results.json');
23
+ const VALIDATION_MD_PATH = resolve(PROJECT_ROOT, 'docs/VALIDATION.md');
24
+ const RUNNER_VERSION = '1.2.0';
25
+
26
+ // Dynamic import of MCP SDK from server's node_modules
27
+ const require = createRequire(resolve(SERVER_DIR, 'package.json'));
28
+
29
+ // ── CLI Args ─────────────────────────────────────────────────────────
30
+ const args = process.argv.slice(2);
31
+ const getArg = (name) => {
32
+ const a = args.find(a => a.startsWith(`--${name}=`));
33
+ return a ? a.split('=')[1] : null;
34
+ };
35
+ const hasFlag = (name) => args.includes(`--${name}`);
36
+
37
+ const filterModule = getArg('module');
38
+ const filterSite = getArg('site');
39
+ const includeWrites = hasFlag('include-writes');
40
+ const testWrites = hasFlag('test-writes');
41
+ const filterTier = getArg('tier') ? parseInt(getArg('tier'), 10) : null;
42
+ const delay = parseInt(getArg('delay') || '100', 10);
43
+ const TIMEOUT_MS = parseInt(getArg('timeout') || '10000', 10);
44
+
45
+ // ── Site Configuration ──────────────────────────────────────────────
46
+ function getConfiguredSites() {
47
+ const raw = process.env.WP_SITES_CONFIG;
48
+ if (!raw) return [];
49
+ try {
50
+ const sites = JSON.parse(raw);
51
+ return sites.map(s => ({ id: s.id, url: s.url }));
52
+ } catch { return []; }
53
+ }
54
+
55
+ // ── Tool Registry ────────────────────────────────────────────────────
56
+ // All 148+ tools classified by module, service, and type.
57
+ const TOOL_REGISTRY = [
58
+ // --- WordPress Core: Content ---
59
+ { name: 'list_content', module: 'unified-content', service: 'wordpress_core', type: 'read', safeArgs: { content_type: 'post', per_page: 1 } },
60
+ { name: 'get_content', module: 'unified-content', service: 'wordpress_core', type: 'read', safeArgs: 'dynamic' },
61
+ { name: 'create_content', module: 'unified-content', service: 'wordpress_core', type: 'write' },
62
+ { name: 'update_content', module: 'unified-content', service: 'wordpress_core', type: 'write' },
63
+ { name: 'delete_content', module: 'unified-content', service: 'wordpress_core', type: 'write' },
64
+ { name: 'discover_content_types', module: 'unified-content', service: 'wordpress_core', type: 'read', safeArgs: {} },
65
+ { name: 'find_content_by_url', module: 'unified-content', service: 'wordpress_core', type: 'read', safeArgs: 'dynamic' },
66
+ { name: 'get_content_by_slug', module: 'unified-content', service: 'wordpress_core', type: 'read', safeArgs: 'dynamic' },
67
+
68
+ // --- WordPress Core: Taxonomies ---
69
+ { name: 'discover_taxonomies', module: 'unified-taxonomies', service: 'wordpress_core', type: 'read', safeArgs: {} },
70
+ { name: 'list_terms', module: 'unified-taxonomies', service: 'wordpress_core', type: 'read', safeArgs: { taxonomy: 'category', per_page: 1 } },
71
+ { name: 'get_term', module: 'unified-taxonomies', service: 'wordpress_core', type: 'read', safeArgs: { taxonomy: 'category', id: 1 } },
72
+ { name: 'create_term', module: 'unified-taxonomies', service: 'wordpress_core', type: 'write' },
73
+ { name: 'update_term', module: 'unified-taxonomies', service: 'wordpress_core', type: 'write' },
74
+ { name: 'delete_term', module: 'unified-taxonomies', service: 'wordpress_core', type: 'write' },
75
+ { name: 'assign_terms_to_content', module: 'unified-taxonomies', service: 'wordpress_core', type: 'write' },
76
+ { name: 'get_content_terms', module: 'unified-taxonomies', service: 'wordpress_core', type: 'read', safeArgs: 'dynamic' },
77
+
78
+ // --- WordPress Core: Comments ---
79
+ { name: 'list_comments', module: 'comments', service: 'wordpress_core', type: 'read', safeArgs: { per_page: 1 } },
80
+ { name: 'get_comment', module: 'comments', service: 'wordpress_core', type: 'read', safeArgs: 'dynamic' },
81
+ { name: 'create_comment', module: 'comments', service: 'wordpress_core', type: 'write' },
82
+ { name: 'update_comment', module: 'comments', service: 'wordpress_core', type: 'write' },
83
+ { name: 'delete_comment', module: 'comments', service: 'wordpress_core', type: 'write' },
84
+
85
+ // --- WordPress Core: Media ---
86
+ { name: 'list_media', module: 'media', service: 'wordpress_core', type: 'read', safeArgs: { per_page: 1 } },
87
+ { name: 'get_media', module: 'media', service: 'wordpress_core', type: 'read', safeArgs: 'dynamic' },
88
+ { name: 'create_media', module: 'media', service: 'wordpress_core', type: 'write' },
89
+ { name: 'edit_media', module: 'media', service: 'wordpress_core', type: 'write' },
90
+ { name: 'delete_media', module: 'media', service: 'wordpress_core', type: 'write' },
91
+
92
+ // --- WordPress Core: Users ---
93
+ { name: 'list_users', module: 'users', service: 'wordpress_core', type: 'read', safeArgs: { per_page: 1 } },
94
+ { name: 'get_user', module: 'users', service: 'wordpress_core', type: 'read', safeArgs: { id: 1 } },
95
+ { name: 'get_me', module: 'users', service: 'wordpress_core', type: 'read', safeArgs: {} },
96
+ { name: 'create_user', module: 'users', service: 'wordpress_core', type: 'write' },
97
+ { name: 'update_user', module: 'users', service: 'wordpress_core', type: 'write' },
98
+ { name: 'delete_user', module: 'users', service: 'wordpress_core', type: 'write' },
99
+
100
+ // --- WordPress Core: Plugins ---
101
+ { name: 'list_plugins', module: 'plugins', service: 'wordpress_core', type: 'read', safeArgs: { status: 'active' } },
102
+ { name: 'get_plugin', module: 'plugins', service: 'wordpress_core', type: 'read', safeArgs: 'dynamic' },
103
+ { name: 'activate_plugin', module: 'plugins', service: 'wordpress_core', type: 'write' },
104
+ { name: 'deactivate_plugin', module: 'plugins', service: 'wordpress_core', type: 'write' },
105
+ { name: 'create_plugin', module: 'plugins', service: 'wordpress_core', type: 'write' },
106
+ { name: 'delete_plugin', module: 'plugins', service: 'wordpress_core', type: 'write' },
107
+
108
+ // --- WordPress Core: Search ---
109
+ { name: 'wp_search', module: 'search', service: 'wordpress_core', type: 'read', safeArgs: { search: 'test', per_page: 1 } },
110
+
111
+ // --- WordPress Core: Plugin Repository ---
112
+ { name: 'search_plugin_repository', module: 'plugin-repository', service: 'wordpress_core', type: 'read', safeArgs: { search: 'seo' } },
113
+ { name: 'get_plugin_details', module: 'plugin-repository', service: 'wordpress_core', type: 'read', safeArgs: { slug: 'akismet' } },
114
+
115
+ // --- WordPress Core: Site Management ---
116
+ { name: 'switch_site', module: 'server', service: 'wordpress_core', type: 'write' },
117
+ { name: 'list_sites', module: 'server', service: 'wordpress_core', type: 'read', safeArgs: {} },
118
+ { name: 'get_active_site', module: 'server', service: 'wordpress_core', type: 'read', safeArgs: {} },
119
+
120
+ // --- Multisite: Network ---
121
+ { name: 'ms_list_network_plugins', module: 'multisite-network', service: 'multisite', type: 'read', safeArgs: {} },
122
+ { name: 'ms_network_activate_plugin', module: 'multisite-network', service: 'multisite', type: 'write' },
123
+ { name: 'ms_network_deactivate_plugin', module: 'multisite-network', service: 'multisite', type: 'write' },
124
+ { name: 'ms_list_super_admins', module: 'multisite-network', service: 'multisite', type: 'read', safeArgs: {} },
125
+ { name: 'ms_get_network_settings', module: 'multisite-network', service: 'multisite', type: 'read', safeArgs: {} },
126
+
127
+ // --- Multisite: Sites ---
128
+ { name: 'ms_list_sites', module: 'multisite-sites', service: 'multisite', type: 'read', safeArgs: {} },
129
+ { name: 'ms_get_site', module: 'multisite-sites', service: 'multisite', type: 'read', safeArgs: { site_id: 1 } },
130
+ { name: 'ms_create_site', module: 'multisite-sites', service: 'multisite', type: 'write' },
131
+ { name: 'ms_activate_site', module: 'multisite-sites', service: 'multisite', type: 'write' },
132
+ { name: 'ms_delete_site', module: 'multisite-sites', service: 'multisite', type: 'write' },
133
+
134
+ // --- WooCommerce: Products ---
135
+ { name: 'wc_list_products', module: 'wc-products', service: 'woocommerce', type: 'read', safeArgs: { per_page: 1 } },
136
+ { name: 'wc_get_product', module: 'wc-products', service: 'woocommerce', type: 'read', safeArgs: { id: 1 } },
137
+ { name: 'wc_create_product', module: 'wc-products', service: 'woocommerce', type: 'write' },
138
+ { name: 'wc_update_product', module: 'wc-products', service: 'woocommerce', type: 'write' },
139
+ { name: 'wc_delete_product', module: 'wc-products', service: 'woocommerce', type: 'write' },
140
+ { name: 'wc_list_product_categories', module: 'wc-products', service: 'woocommerce', type: 'read', safeArgs: { per_page: 1 } },
141
+ { name: 'wc_list_product_variations', module: 'wc-products', service: 'woocommerce', type: 'read', safeArgs: { product_id: 1 } },
142
+
143
+ // --- WooCommerce: Orders ---
144
+ { name: 'wc_list_orders', module: 'wc-orders', service: 'woocommerce', type: 'read', safeArgs: { per_page: 1 } },
145
+ { name: 'wc_get_order', module: 'wc-orders', service: 'woocommerce', type: 'read', safeArgs: { id: 1 } },
146
+ { name: 'wc_update_order_status', module: 'wc-orders', service: 'woocommerce', type: 'write' },
147
+ { name: 'wc_list_order_notes', module: 'wc-orders', service: 'woocommerce', type: 'read', safeArgs: { order_id: 1 } },
148
+ { name: 'wc_create_order_note', module: 'wc-orders', service: 'woocommerce', type: 'write' },
149
+ { name: 'wc_create_refund', module: 'wc-orders', service: 'woocommerce', type: 'write' },
150
+
151
+ // --- WooCommerce: Customers ---
152
+ { name: 'wc_list_customers', module: 'wc-customers', service: 'woocommerce', type: 'read', safeArgs: { per_page: 1 } },
153
+ { name: 'wc_get_customer', module: 'wc-customers', service: 'woocommerce', type: 'read', safeArgs: { id: 1 } },
154
+ { name: 'wc_create_customer', module: 'wc-customers', service: 'woocommerce', type: 'write' },
155
+ { name: 'wc_update_customer', module: 'wc-customers', service: 'woocommerce', type: 'write' },
156
+
157
+ // --- WooCommerce: Coupons ---
158
+ { name: 'wc_list_coupons', module: 'wc-coupons', service: 'woocommerce', type: 'read', safeArgs: { per_page: 1 } },
159
+ { name: 'wc_get_coupon', module: 'wc-coupons', service: 'woocommerce', type: 'read', safeArgs: { id: 1 } },
160
+ { name: 'wc_create_coupon', module: 'wc-coupons', service: 'woocommerce', type: 'write' },
161
+ { name: 'wc_delete_coupon', module: 'wc-coupons', service: 'woocommerce', type: 'write' },
162
+
163
+ // --- WooCommerce: Reports ---
164
+ { name: 'wc_get_sales_report', module: 'wc-reports', service: 'woocommerce', type: 'read', safeArgs: {} },
165
+ { name: 'wc_get_top_sellers', module: 'wc-reports', service: 'woocommerce', type: 'read', safeArgs: {} },
166
+ { name: 'wc_get_orders_totals', module: 'wc-reports', service: 'woocommerce', type: 'read', safeArgs: {} },
167
+ { name: 'wc_get_products_totals', module: 'wc-reports', service: 'woocommerce', type: 'read', safeArgs: {} },
168
+ { name: 'wc_get_customers_totals', module: 'wc-reports', service: 'woocommerce', type: 'read', safeArgs: {} },
169
+
170
+ // --- WooCommerce: Settings ---
171
+ { name: 'wc_list_payment_gateways', module: 'wc-settings', service: 'woocommerce', type: 'read', safeArgs: {} },
172
+ { name: 'wc_list_shipping_zones', module: 'wc-settings', service: 'woocommerce', type: 'read', safeArgs: {} },
173
+ { name: 'wc_get_tax_classes', module: 'wc-settings', service: 'woocommerce', type: 'read', safeArgs: {} },
174
+ { name: 'wc_get_system_status', module: 'wc-settings', service: 'woocommerce', type: 'read', safeArgs: {} },
175
+
176
+ // --- WooCommerce: Webhooks ---
177
+ { name: 'wc_list_webhooks', module: 'wc-webhooks', service: 'woocommerce', type: 'read', safeArgs: {} },
178
+ { name: 'wc_create_webhook', module: 'wc-webhooks', service: 'woocommerce', type: 'write' },
179
+ { name: 'wc_update_webhook', module: 'wc-webhooks', service: 'woocommerce', type: 'write' },
180
+ { name: 'wc_delete_webhook', module: 'wc-webhooks', service: 'woocommerce', type: 'write' },
181
+
182
+ // --- WooCommerce: Workflows ---
183
+ { name: 'wf_list_triggers', module: 'wc-workflows', service: 'woocommerce', type: 'read', safeArgs: {} },
184
+ { name: 'wf_create_trigger', module: 'wc-workflows', service: 'woocommerce', type: 'write' },
185
+ { name: 'wf_update_trigger', module: 'wc-workflows', service: 'woocommerce', type: 'write' },
186
+ { name: 'wf_delete_trigger', module: 'wc-workflows', service: 'woocommerce', type: 'write' },
187
+
188
+ // --- Mailchimp ---
189
+ { name: 'mc_list_audiences', module: 'mailchimp', service: 'mailchimp', type: 'read', safeArgs: {} },
190
+ { name: 'mc_get_audience_members', module: 'mailchimp', service: 'mailchimp', type: 'read', safeArgs: { list_id: 'default' } },
191
+ { name: 'mc_create_campaign', module: 'mailchimp', service: 'mailchimp', type: 'write' },
192
+ { name: 'mc_update_campaign_content', module: 'mailchimp', service: 'mailchimp', type: 'write' },
193
+ { name: 'mc_send_campaign', module: 'mailchimp', service: 'mailchimp', type: 'write' },
194
+ { name: 'mc_get_campaign_report', module: 'mailchimp', service: 'mailchimp', type: 'read', safeArgs: { campaign_id: 'test' } },
195
+ { name: 'mc_add_subscriber', module: 'mailchimp', service: 'mailchimp', type: 'write' },
196
+
197
+ // --- Buffer ---
198
+ { name: 'buf_list_profiles', module: 'buffer', service: 'buffer', type: 'read', safeArgs: {} },
199
+ { name: 'buf_create_update', module: 'buffer', service: 'buffer', type: 'write' },
200
+ { name: 'buf_list_pending', module: 'buffer', service: 'buffer', type: 'read', safeArgs: { profile_id: 'default' } },
201
+ { name: 'buf_list_sent', module: 'buffer', service: 'buffer', type: 'read', safeArgs: { profile_id: 'default' } },
202
+ { name: 'buf_get_analytics', module: 'buffer', service: 'buffer', type: 'read', safeArgs: { profile_id: 'default' } },
203
+
204
+ // --- SendGrid ---
205
+ { name: 'sg_send_email', module: 'sendgrid', service: 'sendgrid', type: 'write' },
206
+ { name: 'sg_list_templates', module: 'sendgrid', service: 'sendgrid', type: 'read', safeArgs: {} },
207
+ { name: 'sg_get_template', module: 'sendgrid', service: 'sendgrid', type: 'read', safeArgs: { template_id: 'test' } },
208
+ { name: 'sg_list_contacts', module: 'sendgrid', service: 'sendgrid', type: 'read', safeArgs: {} },
209
+ { name: 'sg_add_contacts', module: 'sendgrid', service: 'sendgrid', type: 'write' },
210
+ { name: 'sg_get_stats', module: 'sendgrid', service: 'sendgrid', type: 'read', safeArgs: {} },
211
+
212
+ // --- Google Search Console ---
213
+ { name: 'gsc_list_sites', module: 'gsc', service: 'gsc', type: 'read', safeArgs: {} },
214
+ { name: 'gsc_search_analytics', module: 'gsc', service: 'gsc', type: 'read', safeArgs: { start_date: '2025-01-01', end_date: '2025-01-31', dimensions: ['query'], row_limit: 5 } },
215
+ { name: 'gsc_inspect_url', module: 'gsc', service: 'gsc', type: 'read', safeArgs: { url: '/' } },
216
+ { name: 'gsc_list_sitemaps', module: 'gsc', service: 'gsc', type: 'read', safeArgs: {} },
217
+ { name: 'gsc_submit_sitemap', module: 'gsc', service: 'gsc', type: 'write' },
218
+ { name: 'gsc_delete_sitemap', module: 'gsc', service: 'gsc', type: 'write' },
219
+ { name: 'gsc_top_queries', module: 'gsc', service: 'gsc', type: 'read', safeArgs: { days: 7, limit: 5 } },
220
+ { name: 'gsc_page_performance', module: 'gsc', service: 'gsc', type: 'read', safeArgs: { days: 7, limit: 5 } },
221
+
222
+ // --- Google Analytics 4 ---
223
+ { name: 'ga4_run_report', module: 'ga4', service: 'ga4', type: 'read', safeArgs: { dimensions: ['date'], metrics: ['activeUsers'], start_date: '7daysAgo', end_date: 'today' } },
224
+ { name: 'ga4_get_realtime', module: 'ga4', service: 'ga4', type: 'read', safeArgs: {} },
225
+ { name: 'ga4_top_pages', module: 'ga4', service: 'ga4', type: 'read', safeArgs: { days: 7, limit: 5 } },
226
+ { name: 'ga4_traffic_sources', module: 'ga4', service: 'ga4', type: 'read', safeArgs: { days: 7, limit: 5 } },
227
+ { name: 'ga4_user_demographics', module: 'ga4', service: 'ga4', type: 'read', safeArgs: { days: 7 } },
228
+ { name: 'ga4_conversion_events', module: 'ga4', service: 'ga4', type: 'read', safeArgs: { days: 7, limit: 5 } },
229
+
230
+ // --- Plausible ---
231
+ { name: 'pl_get_stats', module: 'plausible', service: 'plausible', type: 'read', safeArgs: { period: '7d' } },
232
+ { name: 'pl_get_timeseries', module: 'plausible', service: 'plausible', type: 'read', safeArgs: { period: '7d' } },
233
+ { name: 'pl_get_breakdown', module: 'plausible', service: 'plausible', type: 'read', safeArgs: { property: 'visit:source', period: '7d' } },
234
+ { name: 'pl_get_realtime', module: 'plausible', service: 'plausible', type: 'read', safeArgs: {} },
235
+
236
+ // --- Core Web Vitals ---
237
+ { name: 'cwv_analyze_url', module: 'cwv', service: 'cwv', type: 'read', safeArgs: { url: 'https://example.com' } },
238
+ { name: 'cwv_batch_analyze', module: 'cwv', service: 'cwv', type: 'read', safeArgs: { urls: ['https://example.com'] } },
239
+ { name: 'cwv_get_field_data', module: 'cwv', service: 'cwv', type: 'read', safeArgs: { url: 'https://example.com' } },
240
+ { name: 'cwv_compare_pages', module: 'cwv', service: 'cwv', type: 'read', safeArgs: { urls: ['https://example.com', 'https://example.org'] } },
241
+
242
+ // --- Slack ---
243
+ { name: 'slack_send_alert', module: 'slack', service: 'slack', type: 'write' },
244
+ { name: 'slack_send_message', module: 'slack', service: 'slack', type: 'write' },
245
+ { name: 'slack_list_channels', module: 'slack', service: 'slack', type: 'read', safeArgs: {} },
246
+
247
+ // --- LinkedIn ---
248
+ { name: 'li_get_profile', module: 'linkedin', service: 'linkedin', type: 'read', safeArgs: {} },
249
+ { name: 'li_create_post', module: 'linkedin', service: 'linkedin', type: 'write' },
250
+ { name: 'li_create_article', module: 'linkedin', service: 'linkedin', type: 'write' },
251
+ { name: 'li_get_analytics', module: 'linkedin', service: 'linkedin', type: 'read', safeArgs: {} },
252
+ { name: 'li_list_posts', module: 'linkedin', service: 'linkedin', type: 'read', safeArgs: {} },
253
+
254
+ // --- Twitter ---
255
+ { name: 'tw_create_tweet', module: 'twitter', service: 'twitter', type: 'write' },
256
+ { name: 'tw_create_thread', module: 'twitter', service: 'twitter', type: 'write' },
257
+ { name: 'tw_get_metrics', module: 'twitter', service: 'twitter', type: 'read', safeArgs: {} },
258
+ { name: 'tw_list_tweets', module: 'twitter', service: 'twitter', type: 'read', safeArgs: {} },
259
+ { name: 'tw_delete_tweet', module: 'twitter', service: 'twitter', type: 'write' },
260
+
261
+ // --- Schema.org Structured Data ---
262
+ { name: 'sd_validate', module: 'schema', service: 'wordpress_core', type: 'read', safeArgs: { url: '/' } },
263
+ { name: 'sd_inject', module: 'schema', service: 'wordpress_core', type: 'write' },
264
+ { name: 'sd_list_schemas', module: 'schema', service: 'wordpress_core', type: 'read', safeArgs: {} },
265
+ ];
266
+
267
+ // Service detection probes: one canonical tool per service
268
+ const SERVICE_PROBES = {
269
+ wordpress_core: 'get_active_site',
270
+ woocommerce: 'wc_list_products',
271
+ multisite: 'ms_list_sites',
272
+ mailchimp: 'mc_list_audiences',
273
+ buffer: 'buf_list_profiles',
274
+ sendgrid: 'sg_list_templates',
275
+ gsc: 'gsc_list_sites',
276
+ ga4: 'ga4_top_pages',
277
+ plausible: 'pl_get_stats',
278
+ cwv: 'cwv_analyze_url',
279
+ slack: 'slack_list_channels',
280
+ linkedin: 'li_get_profile',
281
+ twitter: 'tw_list_tweets',
282
+ };
283
+
284
+ // ── Write Sequences ─────────────────────────────────────────────────
285
+ // CRUD sequences: create → verify → update → verify → delete → verify(404)
286
+ // Each step extracts context for subsequent steps via `extract` and `as`.
287
+ const WRITE_SEQUENCES = [
288
+ // --- Tier 1: Safe standalone CRUD ---
289
+ {
290
+ name: 'content', tier: 1, service: 'wordpress_core',
291
+ steps: [
292
+ { action: 'create', tool: 'create_content', args: { content_type: 'post', title: '[TEST] Validation Post', content: 'Test content — created by validation runner', status: 'draft' }, extract: 'id' },
293
+ { action: 'verify', tool: 'get_content', argsFrom: ctx => ({ content_type: 'post', id: ctx.id }), expect: 'exists' },
294
+ { action: 'update', tool: 'update_content', argsFrom: ctx => ({ content_type: 'post', id: ctx.id, title: '[TEST] Updated Validation Post' }) },
295
+ { action: 'verify', tool: 'get_content', argsFrom: ctx => ({ content_type: 'post', id: ctx.id }), expect: 'exists' },
296
+ { action: 'delete', tool: 'delete_content', argsFrom: ctx => ({ content_type: 'post', id: ctx.id, force: true }) },
297
+ ],
298
+ cleanup: ctx => ctx.id ? { tool: 'delete_content', args: { content_type: 'post', id: ctx.id, force: true } } : null,
299
+ },
300
+ {
301
+ name: 'term', tier: 1, service: 'wordpress_core',
302
+ steps: [
303
+ { action: 'create', tool: 'create_term', args: { taxonomy: 'category', name: '[TEST] Validation Category' }, extract: 'id' },
304
+ { action: 'verify', tool: 'get_term', argsFrom: ctx => ({ taxonomy: 'category', id: ctx.id }), expect: 'exists' },
305
+ { action: 'update', tool: 'update_term', argsFrom: ctx => ({ taxonomy: 'category', id: ctx.id, name: '[TEST] Updated Category' }) },
306
+ { action: 'verify', tool: 'get_term', argsFrom: ctx => ({ taxonomy: 'category', id: ctx.id }), expect: 'exists' },
307
+ { action: 'delete', tool: 'delete_term', argsFrom: ctx => ({ taxonomy: 'category', id: ctx.id, force: true }) },
308
+ ],
309
+ cleanup: ctx => ctx.id ? { tool: 'delete_term', args: { taxonomy: 'category', id: ctx.id, force: true } } : null,
310
+ },
311
+ {
312
+ name: 'comment', tier: 1, service: 'wordpress_core',
313
+ // Needs a real post — uses dynamic postId resolved before running
314
+ steps: [
315
+ { action: 'create', tool: 'create_comment', argsFrom: ctx => ({ post: ctx._postId, content: '[TEST] Validation comment', author_name: 'Validator', author_email: 'test@validation.local' }), extract: 'id' },
316
+ { action: 'verify', tool: 'get_comment', argsFrom: ctx => ({ id: ctx.id }), expect: 'exists' },
317
+ { action: 'update', tool: 'update_comment', argsFrom: ctx => ({ id: ctx.id, content: '[TEST] Updated comment' }) },
318
+ { action: 'verify', tool: 'get_comment', argsFrom: ctx => ({ id: ctx.id }), expect: 'exists' },
319
+ { action: 'delete', tool: 'delete_comment', argsFrom: ctx => ({ id: ctx.id, force: true }) },
320
+ ],
321
+ cleanup: ctx => ctx.id ? { tool: 'delete_comment', args: { id: ctx.id, force: true } } : null,
322
+ },
323
+ {
324
+ name: 'user', tier: 1, service: 'wordpress_core',
325
+ steps: [
326
+ { action: 'create', tool: 'create_user', argsFrom: () => ({ username: 'test_validator_' + Date.now(), email: `validator_${Date.now()}@test.local`, password: 'Val1dation!Test#2026', role: 'subscriber' }), extract: 'id' },
327
+ { action: 'verify', tool: 'get_user', argsFrom: ctx => ({ id: ctx.id }), expect: 'exists' },
328
+ { action: 'update', tool: 'update_user', argsFrom: ctx => ({ id: ctx.id, first_name: 'Test', last_name: 'Validator' }) },
329
+ { action: 'verify', tool: 'get_user', argsFrom: ctx => ({ id: ctx.id }), expect: 'exists' },
330
+ { action: 'delete', tool: 'delete_user', argsFrom: ctx => ({ id: ctx.id, reassign: 1, force: true }) },
331
+ ],
332
+ cleanup: ctx => ctx.id ? { tool: 'delete_user', args: { id: ctx.id, reassign: 1, force: true } } : null,
333
+ },
334
+
335
+ // --- Tier 2: CRUD with dependencies ---
336
+ {
337
+ name: 'media', tier: 2, service: 'wordpress_core',
338
+ steps: [
339
+ { action: 'create', tool: 'create_media', args: { title: '[TEST] Validation Image', source_url: 'https://s.w.org/images/core/emoji/15.0.3/72x72/2705.png' }, extract: 'id' },
340
+ { action: 'verify', tool: 'get_media', argsFrom: ctx => ({ id: ctx.id }), expect: 'exists' },
341
+ { action: 'update', tool: 'edit_media', argsFrom: ctx => ({ id: ctx.id, title: '[TEST] Updated Image', alt_text: 'validation test' }) },
342
+ { action: 'verify', tool: 'get_media', argsFrom: ctx => ({ id: ctx.id }), expect: 'exists' },
343
+ { action: 'delete', tool: 'delete_media', argsFrom: ctx => ({ id: ctx.id, force: true }) },
344
+ ],
345
+ cleanup: ctx => ctx.id ? { tool: 'delete_media', args: { id: ctx.id, force: true } } : null,
346
+ },
347
+ {
348
+ name: 'plugin', tier: 2, service: 'wordpress_core',
349
+ steps: [
350
+ { action: 'create', tool: 'create_plugin', args: { slug: 'hello-dolly', status: 'inactive' }, extract: 'plugin' },
351
+ { action: 'verify', tool: 'get_plugin', argsFrom: ctx => ({ plugin: ctx.plugin }), expect: 'exists' },
352
+ { action: 'update', tool: 'activate_plugin', argsFrom: ctx => ({ plugin: ctx.plugin }) },
353
+ { action: 'update', tool: 'deactivate_plugin', argsFrom: ctx => ({ plugin: ctx.plugin }) },
354
+ { action: 'delete', tool: 'delete_plugin', argsFrom: ctx => ({ plugin: ctx.plugin }) },
355
+ ],
356
+ cleanup: ctx => ctx.plugin ? { tool: 'delete_plugin', args: { plugin: ctx.plugin } } : null,
357
+ },
358
+ {
359
+ name: 'assign_terms', tier: 2, service: 'wordpress_core',
360
+ steps: [
361
+ // Create own test entities
362
+ { action: 'create', tool: 'create_content', args: { content_type: 'post', title: '[TEST] Terms Assignment Post', content: 'test', status: 'draft' }, extract: 'id', as: 'postId' },
363
+ { action: 'create', tool: 'create_term', args: { taxonomy: 'post_tag', name: '[TEST] Validation Tag' }, extract: 'id', as: 'tagId' },
364
+ { action: 'update', tool: 'assign_terms_to_content', argsFrom: ctx => ({ content_id: ctx.postId, content_type: 'post', taxonomy: 'post_tag', terms: [ctx.tagId] }) },
365
+ { action: 'verify', tool: 'get_content_terms', argsFrom: ctx => ({ content_id: ctx.postId, content_type: 'post' }), expect: 'exists' },
366
+ // Cleanup created entities
367
+ { action: 'delete', tool: 'delete_content', argsFrom: ctx => ({ content_type: 'post', id: ctx.postId, force: true }) },
368
+ { action: 'delete', tool: 'delete_term', argsFrom: ctx => ({ taxonomy: 'post_tag', id: ctx.tagId, force: true }) },
369
+ ],
370
+ cleanup: ctx => {
371
+ const tasks = [];
372
+ if (ctx.postId) tasks.push({ tool: 'delete_content', args: { content_type: 'post', id: ctx.postId, force: true } });
373
+ if (ctx.tagId) tasks.push({ tool: 'delete_term', args: { taxonomy: 'post_tag', id: ctx.tagId, force: true } });
374
+ return tasks.length ? tasks : null;
375
+ },
376
+ },
377
+
378
+ // --- Tier 3: Special operations ---
379
+ {
380
+ name: 'switch_site', tier: 3, service: 'wordpress_core',
381
+ steps: [
382
+ { action: 'verify', tool: 'get_active_site', args: {}, extract: 'site_id', as: 'originalSite', extractFn: text => { try { const d = JSON.parse(text); return d.site_id || d.id || text; } catch { return text; } } },
383
+ { action: 'update', tool: 'switch_site', args: { site_id: 'bioinagro' } },
384
+ { action: 'verify', tool: 'get_active_site', args: {}, expect: 'exists' },
385
+ { action: 'update', tool: 'switch_site', argsFrom: ctx => ({ site_id: ctx.originalSite }) },
386
+ ],
387
+ cleanup: null,
388
+ },
389
+ ];
390
+
391
+ // ── Helpers ──────────────────────────────────────────────────────────
392
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
393
+
394
+ function truncate(str, max = 200) {
395
+ if (!str) return '';
396
+ const s = String(str).replace(/\n/g, ' ');
397
+ return s.length > max ? s.slice(0, max) + '...' : s;
398
+ }
399
+
400
+ function log(msg) {
401
+ process.stderr.write(`[validation] ${msg}\n`);
402
+ }
403
+
404
+ // ── Write Sequence Executor ──────────────────────────────────────────
405
+ async function executeWriteSequences(client, services, delay, tierFilter = null) {
406
+ const results = []; // per-tool results
407
+ const cleanupQueue = []; // { tool, args } for emergency cleanup
408
+
409
+ // Resolve a dynamic postId for comment sequence
410
+ let sharedPostId = null;
411
+ if (services.wordpress_core?.configured) {
412
+ try {
413
+ const r = await client.callTool({ name: 'list_content', arguments: { content_type: 'post', per_page: 1 } });
414
+ const text = r.content?.[0]?.text || '';
415
+ sharedPostId = JSON.parse(text)?.[0]?.id || null;
416
+ } catch { /* ignore */ }
417
+ }
418
+
419
+ // Filter sequences by tier and service
420
+ const sequences = WRITE_SEQUENCES.filter(seq => {
421
+ if (tierFilter && seq.tier !== tierFilter) return false;
422
+ const svc = services[seq.service];
423
+ if (!svc || !svc.configured) return false;
424
+ return true;
425
+ });
426
+
427
+ log(`Write sequences to run: ${sequences.map(s => `${s.name}(T${s.tier})`).join(', ') || 'none'}`);
428
+
429
+ for (const seq of sequences) {
430
+ log(`\n ── Sequence: ${seq.name} (Tier ${seq.tier}) ──`);
431
+ const ctx = { _postId: sharedPostId }; // shared context for this sequence
432
+ let aborted = false;
433
+
434
+ for (const step of seq.steps) {
435
+ if (aborted && step.action !== 'delete') {
436
+ // After abort, only attempt deletes (cleanup)
437
+ results.push({
438
+ name: step.tool,
439
+ type: 'write',
440
+ status: 'skipped',
441
+ tested_at: null,
442
+ duration_ms: null,
443
+ response_preview: null,
444
+ error_message: null,
445
+ skip_reason: `Sequence "${seq.name}" aborted at earlier step`,
446
+ });
447
+ continue;
448
+ }
449
+
450
+ // Resolve arguments
451
+ let toolArgs;
452
+ if (step.argsFrom) {
453
+ try {
454
+ toolArgs = step.argsFrom(ctx);
455
+ } catch (err) {
456
+ results.push({
457
+ name: step.tool,
458
+ type: step.action === 'verify' ? 'read' : 'write',
459
+ status: 'error',
460
+ tested_at: new Date().toISOString(),
461
+ duration_ms: 0,
462
+ response_preview: null,
463
+ error_message: `argsFrom failed: ${err.message}`,
464
+ skip_reason: null,
465
+ });
466
+ if (step.action === 'create') aborted = true;
467
+ continue;
468
+ }
469
+ } else {
470
+ toolArgs = step.args || {};
471
+ }
472
+
473
+ // Execute tool call
474
+ const startTime = Date.now();
475
+ const toolResult = {
476
+ name: step.tool,
477
+ type: step.action === 'verify' ? 'read' : 'write',
478
+ status: 'untested',
479
+ tested_at: null,
480
+ duration_ms: null,
481
+ response_preview: null,
482
+ error_message: null,
483
+ skip_reason: null,
484
+ };
485
+
486
+ try {
487
+ const result = await Promise.race([
488
+ client.callTool({ name: step.tool, arguments: toolArgs }),
489
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), TIMEOUT_MS)),
490
+ ]);
491
+ const elapsed = Date.now() - startTime;
492
+ const text = result.content?.map(c => c.text || '').join(' ') || '';
493
+
494
+ toolResult.tested_at = new Date().toISOString();
495
+ toolResult.duration_ms = elapsed;
496
+ toolResult.response_preview = truncate(`Seq:${seq.name} ${step.action} → ${text}`, 200);
497
+
498
+ if (result.isError) {
499
+ toolResult.status = 'failed';
500
+ toolResult.error_message = truncate(text);
501
+ if (step.action === 'create') aborted = true;
502
+ } else {
503
+ toolResult.status = 'passed';
504
+
505
+ // Extract values into context
506
+ if (step.extract) {
507
+ let extracted;
508
+ if (step.extractFn) {
509
+ extracted = step.extractFn(text);
510
+ } else {
511
+ try {
512
+ const parsed = JSON.parse(text);
513
+ extracted = parsed[step.extract];
514
+ } catch {
515
+ // Try regex fallback for "id": 123 pattern
516
+ const m = text.match(new RegExp(`"${step.extract}"\\s*:\\s*(\\d+|"[^"]+")`));
517
+ if (m) extracted = m[1].replace(/"/g, '');
518
+ }
519
+ }
520
+ if (extracted) {
521
+ const key = step.as || step.extract;
522
+ ctx[key] = extracted;
523
+ log(` → extracted ${key}=${extracted}`);
524
+ }
525
+ }
526
+
527
+ // Register for cleanup if this was a create
528
+ if (step.action === 'create' && seq.cleanup) {
529
+ const cleanupSpec = seq.cleanup(ctx);
530
+ if (cleanupSpec) {
531
+ const specs = Array.isArray(cleanupSpec) ? cleanupSpec : [cleanupSpec];
532
+ for (const spec of specs) {
533
+ const tagged = { ...spec, seq: seq.name };
534
+ // Deduplicate: don't re-add if same tool+args already queued
535
+ const dup = cleanupQueue.find(c => c.tool === tagged.tool && c.seq === tagged.seq && JSON.stringify(c.args) === JSON.stringify(tagged.args));
536
+ if (!dup) cleanupQueue.push(tagged);
537
+ }
538
+ }
539
+ }
540
+ }
541
+ } catch (err) {
542
+ toolResult.tested_at = new Date().toISOString();
543
+ toolResult.duration_ms = Date.now() - startTime;
544
+ toolResult.status = 'error';
545
+ toolResult.error_message = truncate(err.message);
546
+ if (step.action === 'create') aborted = true;
547
+ }
548
+
549
+ const icon = { passed: '+', failed: 'X', error: '!', skipped: '-' }[toolResult.status] || '?';
550
+ log(` [${icon}] ${step.action}:${step.tool} (${toolResult.duration_ms}ms)`);
551
+
552
+ // For verify steps, don't add duplicate results — they test read tools
553
+ // Only add results for write tools (create/update/delete)
554
+ if (step.action !== 'verify') {
555
+ results.push(toolResult);
556
+ }
557
+
558
+ // Remove from cleanup queue if delete succeeded
559
+ if (step.action === 'delete' && toolResult.status === 'passed') {
560
+ const idx = cleanupQueue.findIndex(c => c.tool === step.tool && c.seq === seq.name);
561
+ if (idx >= 0) cleanupQueue.splice(idx, 1);
562
+ }
563
+
564
+ await sleep(delay);
565
+ }
566
+ }
567
+
568
+ // ── Emergency Cleanup Phase ─────────────────────────────────────
569
+ if (cleanupQueue.length > 0) {
570
+ log(`\n ── Cleanup: ${cleanupQueue.length} entity(s) to remove ──`);
571
+ for (const item of cleanupQueue) {
572
+ try {
573
+ log(` Cleaning: ${item.tool} ${JSON.stringify(item.args)}`);
574
+ await client.callTool({ name: item.tool, arguments: item.args });
575
+ log(` → cleaned OK`);
576
+ } catch (err) {
577
+ log(` → CLEANUP FAILED: ${err.message}`);
578
+ log(` ⚠ Manual cleanup needed: ${item.tool} ${JSON.stringify(item.args)}`);
579
+ }
580
+ await sleep(delay);
581
+ }
582
+ }
583
+
584
+ return results;
585
+ }
586
+
587
+ // ── Interactive Mode ─────────────────────────────────────────────────
588
+ async function interactiveMode() {
589
+ // Dynamic import of @clack/prompts from server's node_modules
590
+ const clackPath = resolve(SERVER_DIR, 'node_modules/@clack/prompts/dist/index.mjs');
591
+ const p = await import(clackPath);
592
+
593
+ p.intro('WP REST Bridge — Validation Runner v' + RUNNER_VERSION);
594
+
595
+ // 1. Site selection
596
+ const sites = getConfiguredSites();
597
+ let site = null;
598
+ if (sites.length > 0) {
599
+ site = await p.select({
600
+ message: 'Seleziona il sito target',
601
+ options: sites.map(s => ({ value: s.id, label: `${s.id} (${s.url})` })),
602
+ });
603
+ if (p.isCancel(site)) { p.cancel('Operazione annullata.'); return null; }
604
+ } else {
605
+ p.log.warn('Nessun sito configurato in WP_SITES_CONFIG — uso sito di default.');
606
+ }
607
+
608
+ // 2. Validation type
609
+ const validationType = await p.select({
610
+ message: 'Cosa vuoi validare?',
611
+ options: [
612
+ { value: 'read', label: 'Solo read tool' },
613
+ { value: 'write-t1', label: 'Solo write tool (Tier 1 — CRUD base)' },
614
+ { value: 'write-t2', label: 'Solo write tool (Tier 2 — con dipendenze)' },
615
+ { value: 'write-all', label: 'Solo write tool (tutti i tier)' },
616
+ { value: 'all', label: 'Tutto (read + write)' },
617
+ ],
618
+ });
619
+ if (p.isCancel(validationType)) { p.cancel('Operazione annullata.'); return null; }
620
+
621
+ // 3. Module filter
622
+ const uniqueModules = [...new Set(TOOL_REGISTRY.map(t => t.module))].sort();
623
+ const filterMod = await p.select({
624
+ message: 'Filtrare per modulo? (opzionale)',
625
+ options: [
626
+ { value: null, label: 'Tutti i moduli' },
627
+ ...uniqueModules.map(m => ({ value: m, label: m })),
628
+ ],
629
+ });
630
+ if (p.isCancel(filterMod)) { p.cancel('Operazione annullata.'); return null; }
631
+
632
+ // Derive config from selections
633
+ const testReads = validationType === 'read' || validationType === 'all';
634
+ const doTestWrites = validationType.startsWith('write') || validationType === 'all';
635
+ let tier = null;
636
+ if (validationType === 'write-t1') tier = 1;
637
+ else if (validationType === 'write-t2') tier = 2;
638
+
639
+ // 4. Confirmation
640
+ const typeLabel = {
641
+ 'read': 'Read tools',
642
+ 'write-t1': 'Write Tier 1',
643
+ 'write-t2': 'Write Tier 2',
644
+ 'write-all': 'Write tutti i tier',
645
+ 'all': 'Read + Write',
646
+ }[validationType];
647
+ const modLabel = filterMod || 'tutti';
648
+ const siteLabel = site || 'default';
649
+
650
+ const confirmed = await p.confirm({
651
+ message: `Eseguire validazione su ${siteLabel}?\n ${typeLabel} | Modulo: ${modLabel}`,
652
+ });
653
+ if (p.isCancel(confirmed) || !confirmed) { p.cancel('Operazione annullata.'); return null; }
654
+
655
+ return { site, testReads, testWrites: doTestWrites, filterTier: tier, filterModule: filterMod };
656
+ }
657
+
658
+ // ── Main ─────────────────────────────────────────────────────────────
659
+ async function main() {
660
+ // ── Dual Mode Detection ────────────────────────────────────────
661
+ const isInteractive = process.argv.length === 2;
662
+ let config;
663
+
664
+ if (isInteractive) {
665
+ config = await interactiveMode();
666
+ if (!config) process.exit(0); // user cancelled
667
+ } else {
668
+ config = {
669
+ site: filterSite,
670
+ testReads: !testWrites, // default: read if not --test-writes
671
+ testWrites: testWrites,
672
+ filterTier,
673
+ filterModule,
674
+ };
675
+ }
676
+
677
+ log('WP REST Bridge Validation Runner v' + RUNNER_VERSION);
678
+
679
+ // Ensure output directory exists
680
+ mkdirSync(resolve(PROJECT_ROOT, 'docs/validation'), { recursive: true });
681
+
682
+ // Import MCP SDK from server's node_modules
683
+ const { Client } = await import(resolve(SDK_PATH, 'dist/esm/client/index.js'));
684
+ const { StdioClientTransport } = await import(resolve(SDK_PATH, 'dist/esm/client/stdio.js'));
685
+
686
+ const serverPath = resolve(SERVER_DIR, 'build/server.js');
687
+
688
+ log(`Spawning server: node ${serverPath}`);
689
+ const transport = new StdioClientTransport({
690
+ command: 'node',
691
+ args: [serverPath],
692
+ env: { ...process.env },
693
+ });
694
+
695
+ const client = new Client({ name: 'validation-runner', version: RUNNER_VERSION });
696
+
697
+ try {
698
+ await client.connect(transport);
699
+ log('Connected to MCP server');
700
+
701
+ // Switch site if specified (interactive or --site flag)
702
+ if (config.site) {
703
+ log(`Switching to site: ${config.site}`);
704
+ await client.callTool({ name: 'switch_site', arguments: { site_id: config.site } });
705
+ }
706
+
707
+ // List tools from server
708
+ const { tools: serverTools } = await client.listTools();
709
+ const serverToolNames = new Set(serverTools.map(t => t.name));
710
+ log(`Server reports ${serverToolNames.size} tools`);
711
+
712
+ // Warn about unregistered tools
713
+ for (const name of serverToolNames) {
714
+ if (!TOOL_REGISTRY.find(t => t.name === name)) {
715
+ log(`WARNING: Server tool "${name}" not in registry — add it to TOOL_REGISTRY`);
716
+ }
717
+ }
718
+ for (const t of TOOL_REGISTRY) {
719
+ if (!serverToolNames.has(t.name)) {
720
+ log(`WARNING: Registry tool "${t.name}" not found on server`);
721
+ }
722
+ }
723
+
724
+ // Filter registry by module if requested
725
+ let registry = TOOL_REGISTRY;
726
+ if (config.filterModule) {
727
+ registry = registry.filter(t => t.module === config.filterModule);
728
+ log(`Filtered to module "${config.filterModule}": ${registry.length} tools`);
729
+ }
730
+
731
+ // ── Service Detection ──────────────────────────────────────────
732
+ log('Detecting service availability...');
733
+ const services = {};
734
+ for (const [service, probeTool] of Object.entries(SERVICE_PROBES)) {
735
+ if (!serverToolNames.has(probeTool)) {
736
+ services[service] = { configured: false, reason: 'probe tool not on server' };
737
+ continue;
738
+ }
739
+ try {
740
+ const probeEntry = TOOL_REGISTRY.find(t => t.name === probeTool);
741
+ const probeArgs = probeEntry?.safeArgs || {};
742
+ const result = await client.callTool({ name: probeTool, arguments: probeArgs });
743
+ const text = result.content?.map(c => c.text || '').join(' ') || '';
744
+ if (text.includes('not configured') || text.includes('Not configured')) {
745
+ services[service] = { configured: false, reason: truncate(text, 100) };
746
+ } else if (result.isError) {
747
+ services[service] = { configured: false, reason: truncate(text, 100) };
748
+ } else {
749
+ services[service] = { configured: true };
750
+ }
751
+ } catch (err) {
752
+ services[service] = { configured: false, reason: truncate(err.message, 100) };
753
+ }
754
+ await sleep(delay);
755
+ }
756
+ log('Service detection complete: ' + Object.entries(services).map(([k, v]) => `${k}=${v.configured ? 'OK' : 'NO'}`).join(', '));
757
+
758
+ // ── Write Sequence Testing (--test-writes or interactive) ───
759
+ if (config.testWrites) {
760
+ log('\n=== WRITE TOOL TESTING (CRUD Sequences) ===');
761
+ const writeResults = await executeWriteSequences(client, services, delay, config.filterTier);
762
+
763
+ // Merge write results into existing results.json
764
+ let existing = { meta: {}, services: {}, modules: {}, summary: { by_status: {} } };
765
+ if (existsSync(RESULTS_PATH)) {
766
+ try {
767
+ existing = JSON.parse(readFileSync(RESULTS_PATH, 'utf-8'));
768
+ } catch { /* start fresh if corrupted */ }
769
+ }
770
+
771
+ // Update meta
772
+ existing.meta = {
773
+ ...existing.meta,
774
+ generated_at: new Date().toISOString(),
775
+ runner_version: RUNNER_VERSION,
776
+ last_write_test: new Date().toISOString(),
777
+ write_test_tier: config.filterTier || 'all',
778
+ };
779
+ existing.services = { ...existing.services, ...services };
780
+
781
+ // Update individual tool results within modules
782
+ for (const wr of writeResults) {
783
+ // Find which module this tool belongs to
784
+ const regEntry = TOOL_REGISTRY.find(t => t.name === wr.name);
785
+ if (!regEntry) continue;
786
+ const mod = regEntry.module;
787
+ if (!existing.modules[mod]) {
788
+ existing.modules[mod] = { service: regEntry.service, tools: [] };
789
+ }
790
+ // Replace existing result for this tool, or add new
791
+ const idx = existing.modules[mod].tools.findIndex(t => t.name === wr.name);
792
+ if (idx >= 0) {
793
+ existing.modules[mod].tools[idx] = wr;
794
+ } else {
795
+ existing.modules[mod].tools.push(wr);
796
+ }
797
+ }
798
+
799
+ // Recalculate summary
800
+ const allTools = Object.values(existing.modules).flatMap(m => m.tools);
801
+ existing.summary.by_status = {};
802
+ for (const status of ['passed', 'failed', 'error', 'not_configured', 'skipped_write', 'skipped', 'untested']) {
803
+ existing.summary.by_status[status] = allTools.filter(r => r.status === status).length;
804
+ }
805
+
806
+ writeFileSync(RESULTS_PATH, JSON.stringify(existing, null, 2));
807
+ generateMarkdown(existing);
808
+
809
+ // Print summary
810
+ const wPassed = writeResults.filter(r => r.status === 'passed').length;
811
+ const wFailed = writeResults.filter(r => r.status === 'failed').length;
812
+ const wError = writeResults.filter(r => r.status === 'error').length;
813
+ const wSkipped = writeResults.filter(r => r.status === 'skipped').length;
814
+ log(`\nWrite test summary: ${wPassed} passed, ${wFailed} failed, ${wError} errors, ${wSkipped} skipped`);
815
+ log(`Results merged into ${RESULTS_PATH}`);
816
+ log(`Markdown updated at ${VALIDATION_MD_PATH}`);
817
+
818
+ // If not also testing reads, we're done
819
+ if (!config.testReads) {
820
+ await client.close();
821
+ return;
822
+ }
823
+ }
824
+
825
+ // ── Dynamic Args Resolution ──────────────────────────────────
826
+ // For tools marked safeArgs:'dynamic', discover real IDs from the live site
827
+ const dynamicArgs = {};
828
+ if (services.wordpress_core?.configured) {
829
+ log('Resolving dynamic test arguments...');
830
+ try {
831
+ // Discover a real post ID and slug
832
+ const postsResult = await client.callTool({ name: 'list_content', arguments: { content_type: 'post', per_page: 1 } });
833
+ const postsText = postsResult.content?.[0]?.text || '';
834
+ const postId = JSON.parse(postsText)?.[0]?.id;
835
+ const postSlug = JSON.parse(postsText)?.[0]?.slug;
836
+ if (postId) {
837
+ dynamicArgs.get_content = { content_type: 'post', id: postId };
838
+ dynamicArgs.get_content_terms = { content_id: postId, content_type: 'post' };
839
+ log(` Post ID=${postId}, slug="${postSlug}"`);
840
+ }
841
+ if (postSlug) {
842
+ dynamicArgs.get_content_by_slug = { slug: postSlug, content_types: ['post'] };
843
+ dynamicArgs.find_content_by_url = { url: '/' + postSlug + '/' };
844
+ }
845
+ await sleep(delay);
846
+
847
+ // Discover a real comment ID
848
+ const commentsResult = await client.callTool({ name: 'list_comments', arguments: { per_page: 1 } });
849
+ const commentsText = commentsResult.content?.[0]?.text || '';
850
+ const commentId = JSON.parse(commentsText)?.[0]?.id;
851
+ if (commentId) {
852
+ dynamicArgs.get_comment = { id: commentId };
853
+ log(` Comment ID=${commentId}`);
854
+ }
855
+ await sleep(delay);
856
+
857
+ // Discover a real plugin name
858
+ const pluginsResult = await client.callTool({ name: 'list_plugins', arguments: { status: 'active' } });
859
+ const pluginsText = pluginsResult.content?.[0]?.text || '';
860
+ try {
861
+ const plugins = JSON.parse(pluginsText);
862
+ const firstPlugin = Array.isArray(plugins) ? plugins[0] : Object.values(plugins)[0];
863
+ const pluginSlug = firstPlugin?.plugin || firstPlugin?.textdomain;
864
+ if (pluginSlug) {
865
+ dynamicArgs.get_plugin = { plugin: pluginSlug };
866
+ log(` Plugin="${pluginSlug}"`);
867
+ }
868
+ } catch { /* skip if parsing fails */ }
869
+ await sleep(delay);
870
+
871
+ // Discover a real media ID
872
+ const mediaResult = await client.callTool({ name: 'list_media', arguments: { per_page: 1 } });
873
+ const mediaText = mediaResult.content?.[0]?.text || '';
874
+ const mediaId = JSON.parse(mediaText)?.[0]?.id;
875
+ if (mediaId) {
876
+ dynamicArgs.get_media = { id: mediaId };
877
+ log(` Media ID=${mediaId}`);
878
+ }
879
+ await sleep(delay);
880
+ } catch (err) {
881
+ log(` Dynamic resolution error: ${err.message}`);
882
+ }
883
+ }
884
+
885
+ // ── Tool Testing ───────────────────────────────────────────────
886
+ log('Testing tools...');
887
+ const toolResults = [];
888
+
889
+ for (const entry of registry) {
890
+ const toolResult = {
891
+ name: entry.name,
892
+ type: entry.type,
893
+ status: 'untested',
894
+ tested_at: null,
895
+ duration_ms: null,
896
+ response_preview: null,
897
+ error_message: null,
898
+ skip_reason: null,
899
+ };
900
+
901
+ // Skip writes unless --include-writes or interactive "all" mode
902
+ if (entry.type === 'write' && !includeWrites && !config.testWrites) {
903
+ toolResult.status = 'skipped_write';
904
+ toolResult.skip_reason = 'Write tool — use --include-writes to test';
905
+ toolResults.push(toolResult);
906
+ continue;
907
+ }
908
+
909
+ // Skip if service not configured
910
+ const svc = services[entry.service];
911
+ if (svc && !svc.configured) {
912
+ toolResult.status = 'not_configured';
913
+ toolResult.skip_reason = `Service "${entry.service}" not configured`;
914
+ toolResults.push(toolResult);
915
+ continue;
916
+ }
917
+
918
+ // Resolve dynamic safeArgs
919
+ let resolvedArgs = entry.safeArgs;
920
+ if (entry.safeArgs === 'dynamic') {
921
+ resolvedArgs = dynamicArgs[entry.name] || null;
922
+ if (!resolvedArgs) {
923
+ toolResult.status = 'skipped';
924
+ toolResult.skip_reason = 'No data found for dynamic args resolution';
925
+ toolResults.push(toolResult);
926
+ continue;
927
+ }
928
+ }
929
+
930
+ // Skip if no safeArgs defined for read tool
931
+ if (!resolvedArgs) {
932
+ toolResult.status = 'skipped';
933
+ toolResult.skip_reason = 'No safe test arguments defined';
934
+ toolResults.push(toolResult);
935
+ continue;
936
+ }
937
+
938
+ // Skip if tool not on server
939
+ if (!serverToolNames.has(entry.name)) {
940
+ toolResult.status = 'skipped';
941
+ toolResult.skip_reason = 'Tool not registered on server';
942
+ toolResults.push(toolResult);
943
+ continue;
944
+ }
945
+
946
+ // Execute tool call
947
+ const startTime = Date.now();
948
+ try {
949
+ const result = await Promise.race([
950
+ client.callTool({ name: entry.name, arguments: resolvedArgs }),
951
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), TIMEOUT_MS)),
952
+ ]);
953
+ const elapsed = Date.now() - startTime;
954
+ const text = result.content?.map(c => c.text || '').join(' ') || '';
955
+
956
+ toolResult.tested_at = new Date().toISOString();
957
+ toolResult.duration_ms = elapsed;
958
+ toolResult.response_preview = truncate(text);
959
+
960
+ if (result.isError) {
961
+ toolResult.status = text.includes('not configured') || text.includes('Not configured')
962
+ ? 'not_configured' : 'failed';
963
+ toolResult.error_message = truncate(text);
964
+ } else {
965
+ toolResult.status = 'passed';
966
+ }
967
+ } catch (err) {
968
+ toolResult.tested_at = new Date().toISOString();
969
+ toolResult.duration_ms = Date.now() - startTime;
970
+ toolResult.status = 'error';
971
+ toolResult.error_message = truncate(err.message);
972
+ }
973
+
974
+ const icon = { passed: '+', failed: 'X', error: '!', not_configured: '~' }[toolResult.status] || '?';
975
+ log(` [${icon}] ${entry.name} (${toolResult.duration_ms}ms)`);
976
+ toolResults.push(toolResult);
977
+ await sleep(delay);
978
+ }
979
+
980
+ // ── Build Results ──────────────────────────────────────────────
981
+ // Group by module
982
+ const modules = {};
983
+ for (const entry of registry) {
984
+ if (!modules[entry.module]) {
985
+ modules[entry.module] = { service: entry.service, tools: [] };
986
+ }
987
+ const result = toolResults.find(r => r.name === entry.name);
988
+ if (result) modules[entry.module].tools.push(result);
989
+ }
990
+
991
+ // Summary
992
+ const summary = { by_status: {} };
993
+ for (const status of ['passed', 'failed', 'error', 'not_configured', 'skipped_write', 'skipped', 'untested']) {
994
+ summary.by_status[status] = toolResults.filter(r => r.status === status).length;
995
+ }
996
+
997
+ // Get active site
998
+ let activeSite = 'unknown';
999
+ try {
1000
+ const siteResult = await client.callTool({ name: 'get_active_site', arguments: {} });
1001
+ activeSite = siteResult.content?.[0]?.text || 'unknown';
1002
+ } catch { /* ignore */ }
1003
+
1004
+ const results = {
1005
+ meta: {
1006
+ generated_at: new Date().toISOString(),
1007
+ active_site: activeSite,
1008
+ total_tools_registered: TOOL_REGISTRY.length,
1009
+ total_tools_on_server: serverToolNames.size,
1010
+ runner_version: RUNNER_VERSION,
1011
+ },
1012
+ services,
1013
+ modules,
1014
+ summary,
1015
+ };
1016
+
1017
+ // ── Merge incrementale ─────────────────────────────────────────
1018
+ if (config.filterModule && existsSync(RESULTS_PATH)) {
1019
+ log(`Merging results for module "${config.filterModule}" into existing results.json`);
1020
+ const existing = JSON.parse(readFileSync(RESULTS_PATH, 'utf-8'));
1021
+ existing.meta = results.meta;
1022
+ existing.services = { ...existing.services, ...results.services };
1023
+ for (const [mod, data] of Object.entries(results.modules)) {
1024
+ existing.modules[mod] = data;
1025
+ }
1026
+ // Recalculate summary from all modules
1027
+ const allTools = Object.values(existing.modules).flatMap(m => m.tools);
1028
+ for (const status of Object.keys(existing.summary.by_status)) {
1029
+ existing.summary.by_status[status] = allTools.filter(r => r.status === status).length;
1030
+ }
1031
+ writeFileSync(RESULTS_PATH, JSON.stringify(existing, null, 2));
1032
+ generateMarkdown(existing);
1033
+ } else {
1034
+ writeFileSync(RESULTS_PATH, JSON.stringify(results, null, 2));
1035
+ generateMarkdown(results);
1036
+ }
1037
+
1038
+ log(`Results written to ${RESULTS_PATH}`);
1039
+ log(`Markdown written to ${VALIDATION_MD_PATH}`);
1040
+ log(`Summary: ${Object.entries(summary.by_status).map(([k, v]) => `${k}=${v}`).join(', ')}`);
1041
+
1042
+ await client.close();
1043
+ } catch (err) {
1044
+ log(`Fatal error: ${err.message}`);
1045
+ try { await client.close(); } catch { /* ignore */ }
1046
+ process.exit(1);
1047
+ }
1048
+ }
1049
+
1050
+ // ── Markdown Generator ─────────────────────────────────────────────
1051
+ function generateMarkdown(results) {
1052
+ const { meta, services, modules, summary } = results;
1053
+ const lines = [];
1054
+
1055
+ lines.push('# WP REST Bridge — Validation Report');
1056
+ lines.push('');
1057
+ lines.push(`> Generated: ${meta.generated_at} `);
1058
+ lines.push(`> Active site: \`${meta.active_site}\` `);
1059
+ lines.push(`> Tools registered: ${meta.total_tools_registered} | On server: ${meta.total_tools_on_server} `);
1060
+ lines.push(`> Runner: v${meta.runner_version}`);
1061
+ lines.push('');
1062
+
1063
+ // Service Configuration
1064
+ lines.push('## Service Configuration');
1065
+ lines.push('');
1066
+ lines.push('| Service | Status | Note |');
1067
+ lines.push('|---------|--------|------|');
1068
+ for (const [svc, info] of Object.entries(services)) {
1069
+ const icon = info.configured ? 'OK' : 'NO';
1070
+ lines.push(`| ${svc} | ${icon} | ${info.reason || ''} |`);
1071
+ }
1072
+ lines.push('');
1073
+
1074
+ // Summary
1075
+ lines.push('## Summary');
1076
+ lines.push('');
1077
+ lines.push('| Status | Count |');
1078
+ lines.push('|--------|-------|');
1079
+ for (const [status, count] of Object.entries(summary.by_status)) {
1080
+ lines.push(`| ${status} | ${count} |`);
1081
+ }
1082
+ const total = Object.values(summary.by_status).reduce((a, b) => a + b, 0);
1083
+ lines.push(`| **Total** | **${total}** |`);
1084
+ lines.push('');
1085
+
1086
+ // Tool Inventory by Module
1087
+ lines.push('## Tool Inventory by Module');
1088
+ lines.push('');
1089
+ for (const [mod, data] of Object.entries(modules)) {
1090
+ lines.push(`### ${mod} (${data.service})`);
1091
+ lines.push('');
1092
+ lines.push('| Tool | Type | Status | Tested | Note |');
1093
+ lines.push('|------|------|--------|--------|------|');
1094
+ for (const t of data.tools) {
1095
+ const date = t.tested_at ? t.tested_at.split('T')[0] : '';
1096
+ const dur = t.duration_ms != null ? `${t.duration_ms}ms` : '';
1097
+ const note = t.error_message || t.skip_reason || (dur ? dur : '');
1098
+ lines.push(`| ${t.name} | ${t.type.toUpperCase()} | ${t.status} | ${date} | ${truncate(note, 80)} |`);
1099
+ }
1100
+ lines.push('');
1101
+ }
1102
+
1103
+ // Failed Tools Detail
1104
+ const failed = Object.values(modules)
1105
+ .flatMap(m => m.tools)
1106
+ .filter(t => t.status === 'failed' || t.status === 'error');
1107
+ if (failed.length > 0) {
1108
+ lines.push('## Failed Tools Detail');
1109
+ lines.push('');
1110
+ for (const t of failed) {
1111
+ lines.push(`### ${t.name} (${t.status})`);
1112
+ lines.push(`- **Tested**: ${t.tested_at}`);
1113
+ lines.push(`- **Duration**: ${t.duration_ms}ms`);
1114
+ lines.push(`- **Error**: ${t.error_message}`);
1115
+ if (t.response_preview) lines.push(`- **Response**: ${t.response_preview}`);
1116
+ lines.push('');
1117
+ }
1118
+ }
1119
+
1120
+ // Changelog
1121
+ lines.push('## Changelog');
1122
+ lines.push('');
1123
+ lines.push(`- ${meta.generated_at} — Run on \`${meta.active_site}\`: ${Object.entries(summary.by_status).map(([k, v]) => `${k}=${v}`).join(', ')}`);
1124
+ lines.push('');
1125
+
1126
+ writeFileSync(VALIDATION_MD_PATH, lines.join('\n'));
1127
+ }
1128
+
1129
+ main().catch(err => {
1130
+ log(`Unhandled error: ${err.message}`);
1131
+ process.exit(1);
1132
+ });