@adsim/wordpress-mcp-server 4.6.0 → 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.
Files changed (48) hide show
  1. package/.env.example +18 -0
  2. package/README.md +851 -499
  3. package/companion/mcp-diagnostics.php +1184 -0
  4. package/dxt/manifest.json +715 -98
  5. package/index.js +166 -4786
  6. package/package.json +14 -6
  7. package/src/data/plugin-performance-data.json +59 -0
  8. package/src/shared/api.js +79 -0
  9. package/src/shared/audit.js +39 -0
  10. package/src/shared/context.js +15 -0
  11. package/src/shared/governance.js +98 -0
  12. package/src/shared/utils.js +148 -0
  13. package/src/tools/comments.js +50 -0
  14. package/src/tools/content.js +353 -0
  15. package/src/tools/core.js +114 -0
  16. package/src/tools/editorial.js +634 -0
  17. package/src/tools/fse.js +370 -0
  18. package/src/tools/health.js +160 -0
  19. package/src/tools/index.js +96 -0
  20. package/src/tools/intelligence.js +2082 -0
  21. package/src/tools/links.js +118 -0
  22. package/src/tools/media.js +71 -0
  23. package/src/tools/performance.js +219 -0
  24. package/src/tools/plugins.js +368 -0
  25. package/src/tools/schema.js +417 -0
  26. package/src/tools/security.js +590 -0
  27. package/src/tools/seo.js +1633 -0
  28. package/src/tools/taxonomy.js +115 -0
  29. package/src/tools/users.js +188 -0
  30. package/src/tools/woocommerce.js +1008 -0
  31. package/src/tools/workflow.js +409 -0
  32. package/src/transport/http.js +39 -0
  33. package/tests/unit/helpers/pagination.test.js +43 -0
  34. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  35. package/tests/unit/tools/diagnostics.test.js +397 -0
  36. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  37. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  38. package/tests/unit/tools/fse.test.js +548 -0
  39. package/tests/unit/tools/multilingual.test.js +653 -0
  40. package/tests/unit/tools/performance.test.js +351 -0
  41. package/tests/unit/tools/runWorkflow.test.js +150 -0
  42. package/tests/unit/tools/schema.test.js +477 -0
  43. package/tests/unit/tools/security.test.js +695 -0
  44. package/tests/unit/tools/site.test.js +1 -1
  45. package/tests/unit/tools/users.crud.test.js +399 -0
  46. package/tests/unit/tools/validateBlocks.test.js +186 -0
  47. package/tests/unit/tools/visualStaging.test.js +271 -0
  48. package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
@@ -0,0 +1,417 @@
1
+ // src/tools/schema.js — schema tools (7)
2
+ // Definitions + handlers (v5.0.0 refactor Step B+C)
3
+
4
+ import { json, strip } from '../shared/utils.js';
5
+ import { validateInput } from '../shared/governance.js';
6
+ import { rt } from '../shared/context.js';
7
+
8
+ export const definitions = [
9
+ { name: 'wp_generate_schema_article', _category: 'schema', description: 'Use to generate JSON-LD Article schema for a post. Fetches post via REST with _embed and builds complete Article markup with headline, author, dates, image, publisher. Read-only.',
10
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post ID' } }, required: ['post_id'] }},
11
+ { name: 'wp_generate_schema_faq', _category: 'schema', description: 'Use to generate JSON-LD FAQPage schema from post content. Detects Q&A from Gutenberg FAQ blocks, RankMath FAQ, AIOSEO FAQ, <details><summary>, or H3+paragraph patterns. Read-only.',
12
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post ID' } }, required: ['post_id'] }},
13
+ { name: 'wp_generate_schema_howto', _category: 'schema', description: 'Use to generate JSON-LD HowTo schema from post content. Detects steps from ordered lists, numbered H3/H4 headings. Extracts totalTime and estimatedCost if mentioned. Read-only.',
14
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post ID' } }, required: ['post_id'] }},
15
+ { name: 'wp_generate_schema_localbusiness', _category: 'schema', description: 'Use to generate JSON-LD LocalBusiness schema. Pulls data from ACF fields, Yoast Local SEO, or generic WP options. Read-only.',
16
+ inputSchema: { type: 'object', properties: { target: { type: 'string', description: '"site" (default) or a post ID to use post-level ACF fields' } }}},
17
+ { name: 'wp_generate_schema_breadcrumb', _category: 'schema', description: 'Use to generate JSON-LD BreadcrumbList schema for a post or page. Rebuilds full hierarchy: Home > Category/Parent > Post. Read-only.',
18
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post ID' } }, required: ['post_id'] }},
19
+ { name: 'wp_inject_schema', _category: 'schema', description: 'Use to inject generated JSON-LD schema into a post _custom_schema_jsonld meta field. The companion mu-plugin outputs it in wp_head. Write — blocked by WP_READ_ONLY (except dry_run=true).',
20
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post ID' }, schema_type: { type: 'string', enum: ['article', 'faq', 'howto', 'localbusiness', 'breadcrumb'], description: 'Schema type to generate and inject' }, override: { type: 'boolean', description: 'If false (default) and meta exists, returns error with existing content' }, dry_run: { type: 'boolean', description: 'If true, generates schema without injecting. Bypasses WP_READ_ONLY.' } }, required: ['post_id', 'schema_type'] }},
21
+ { name: 'wp_validate_schema_live', _category: 'schema', description: 'Use to validate JSON-LD schema on a live URL. Extracts all <script type="application/ld+json"> blocks, validates structure, checks required fields per type, detects common errors. Read-only.',
22
+ inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'Public URL to validate' } }, required: ['url'] }}
23
+ ];
24
+
25
+ export const handlers = {};
26
+
27
+ handlers['wp_generate_schema_article'] = async (args) => {
28
+ const t0 = Date.now();
29
+ let result;
30
+ const { wpApiCall, auditLog, name } = rt;
31
+ validateInput(args, { post_id: { type: 'number', required: true, min: 1 } });
32
+ const artPost = await wpApiCall(`/posts/${args.post_id}?_embed=true`);
33
+ const artTitle = artPost.title?.rendered || artPost.title?.raw || '';
34
+ const artExcerpt = strip(artPost.excerpt?.rendered || '');
35
+ const artAuthor = artPost._embedded?.author?.[0];
36
+ const artImage = artPost._embedded?.['wp:featuredmedia']?.[0];
37
+ let artPublisher = { '@type': 'Organization' };
38
+ try {
39
+ const siteOpts = await wpApiCall('/settings', { basePath: '/wp-json/wp/v2' });
40
+ artPublisher.name = siteOpts.title || siteOpts.name || '';
41
+ if (siteOpts.site_icon_url) artPublisher.logo = { '@type': 'ImageObject', url: siteOpts.site_icon_url };
42
+ } catch (_e) {
43
+ try { const siteInfo = await wpApiCall('/', { basePath: '/wp-json' }); artPublisher.name = siteInfo.name || ''; } catch (_e2) { /* ignore */ }
44
+ }
45
+ const artMissing = [];
46
+ if (!artTitle) artMissing.push('headline');
47
+ if (!artExcerpt) artMissing.push('description');
48
+ if (!artAuthor?.name) artMissing.push('author');
49
+ if (!artImage?.source_url) artMissing.push('image');
50
+ if (!artPublisher.name) artMissing.push('publisher');
51
+ const artSchema = {
52
+ '@context': 'https://schema.org', '@type': 'Article',
53
+ headline: artTitle,
54
+ description: artExcerpt || undefined,
55
+ datePublished: artPost.date || undefined,
56
+ dateModified: artPost.modified || undefined,
57
+ url: artPost.link || undefined,
58
+ author: artAuthor?.name ? { '@type': 'Person', name: artAuthor.name, url: artAuthor.link || undefined } : undefined,
59
+ image: artImage?.source_url ? { '@type': 'ImageObject', url: artImage.source_url, width: artImage.media_details?.width, height: artImage.media_details?.height } : undefined,
60
+ publisher: artPublisher.name ? artPublisher : undefined,
61
+ };
62
+ // Remove undefined fields
63
+ Object.keys(artSchema).forEach(k => artSchema[k] === undefined && delete artSchema[k]);
64
+ result = json({ jsonld: artSchema, fields_present: Object.keys(artSchema).filter(k => k !== '@context' && k !== '@type'), fields_missing: artMissing });
65
+ auditLog({ tool: name, action: 'generate_schema_article', status: 'success', latency_ms: Date.now() - t0, params: { post_id: args.post_id } });
66
+ return result;
67
+ };
68
+ handlers['wp_generate_schema_faq'] = async (args) => {
69
+ const t0 = Date.now();
70
+ let result;
71
+ const { wpApiCall, auditLog, name } = rt;
72
+ validateInput(args, { post_id: { type: 'number', required: true, min: 1 } });
73
+ const faqPost = await wpApiCall(`/posts/${args.post_id}`);
74
+ const faqHtml = faqPost.content?.rendered || '';
75
+ const faqPairs = [];
76
+ let faqMethod = 'none';
77
+ // 1. Gutenberg FAQ blocks — detect container then extract Q&A pairs
78
+ if (/wp-block-faq/i.test(faqHtml)) {
79
+ const gqRe = /<[^>]*class="[^"]*faq-question[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/gi;
80
+ const gaRe = /<[^>]*class="[^"]*faq-answer[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/gi;
81
+ const gQuestions = [...faqHtml.matchAll(gqRe)].map(m => strip(m[1]));
82
+ const gAnswers = [...faqHtml.matchAll(gaRe)].map(m => strip(m[1]));
83
+ for (let i = 0; i < Math.min(gQuestions.length, gAnswers.length); i++) {
84
+ faqPairs.push({ question: gQuestions[i], answer: gAnswers[i] });
85
+ }
86
+ }
87
+ if (faqPairs.length > 0) faqMethod = 'gutenberg_faq_block';
88
+ // 2. RankMath FAQ — detect container then extract Q&A pairs
89
+ if (faqPairs.length === 0 && /rank-math-faq-block/i.test(faqHtml)) {
90
+ const rmqRe = /<[^>]*class="[^"]*faq-question[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/gi;
91
+ const rmaRe = /<[^>]*class="[^"]*faq-answer[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/gi;
92
+ const rmQuestions = [...faqHtml.matchAll(rmqRe)].map(m => strip(m[1]));
93
+ const rmAnswers = [...faqHtml.matchAll(rmaRe)].map(m => strip(m[1]));
94
+ for (let i = 0; i < Math.min(rmQuestions.length, rmAnswers.length); i++) {
95
+ faqPairs.push({ question: rmQuestions[i], answer: rmAnswers[i] });
96
+ }
97
+ if (faqPairs.length > 0) faqMethod = 'rankmath_faq';
98
+ }
99
+ // 3. AIOSEO FAQ — detect container then extract Q&A pairs
100
+ if (faqPairs.length === 0 && /aioseo-faq/i.test(faqHtml)) {
101
+ const aioqRe = /<[^>]*class="[^"]*faq-question[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/gi;
102
+ const aioaRe = /<[^>]*class="[^"]*faq-answer[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/gi;
103
+ const aioQuestions = [...faqHtml.matchAll(aioqRe)].map(m => strip(m[1]));
104
+ const aioAnswers = [...faqHtml.matchAll(aioaRe)].map(m => strip(m[1]));
105
+ for (let i = 0; i < Math.min(aioQuestions.length, aioAnswers.length); i++) {
106
+ faqPairs.push({ question: aioQuestions[i], answer: aioAnswers[i] });
107
+ }
108
+ if (faqPairs.length > 0) faqMethod = 'aioseo_faq';
109
+ }
110
+ // 4. <details><summary> pattern
111
+ if (faqPairs.length === 0) {
112
+ const detRe = /<details[^>]*>\s*<summary[^>]*>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gi;
113
+ let detM;
114
+ while ((detM = detRe.exec(faqHtml)) !== null) {
115
+ faqPairs.push({ question: strip(detM[1]), answer: strip(detM[2]) });
116
+ }
117
+ if (faqPairs.length > 0) faqMethod = 'details_summary';
118
+ }
119
+ // 5. H3 + next paragraph fallback
120
+ if (faqPairs.length === 0) {
121
+ const h3Re = /<h3[^>]*>([\s\S]*?)<\/h3>\s*<p[^>]*>([\s\S]*?)<\/p>/gi;
122
+ let h3M;
123
+ while ((h3M = h3Re.exec(faqHtml)) !== null) {
124
+ faqPairs.push({ question: strip(h3M[1]), answer: strip(h3M[2]) });
125
+ }
126
+ if (faqPairs.length > 0) faqMethod = 'h3_paragraph';
127
+ }
128
+ const faqSchema = {
129
+ '@context': 'https://schema.org', '@type': 'FAQPage',
130
+ mainEntity: faqPairs.map(p => ({ '@type': 'Question', name: p.question, acceptedAnswer: { '@type': 'Answer', text: p.answer } }))
131
+ };
132
+ result = json({ jsonld: faqSchema, qa_count: faqPairs.length, detection_method: faqMethod });
133
+ auditLog({ tool: name, action: 'generate_schema_faq', status: 'success', latency_ms: Date.now() - t0, params: { post_id: args.post_id } });
134
+ return result;
135
+ };
136
+ handlers['wp_generate_schema_howto'] = async (args) => {
137
+ const t0 = Date.now();
138
+ let result;
139
+ const { wpApiCall, auditLog, name } = rt;
140
+ validateInput(args, { post_id: { type: 'number', required: true, min: 1 } });
141
+ const htPost = await wpApiCall(`/posts/${args.post_id}`);
142
+ const htHtml = htPost.content?.rendered || '';
143
+ const htSteps = [];
144
+ let htMethod = 'none';
145
+ // 1. Ordered lists (ol > li)
146
+ const olRe = /<ol[^>]*class="[^"]*wp-block-list[^"]*"[^>]*>([\s\S]*?)<\/ol>/gi;
147
+ let olM;
148
+ while ((olM = olRe.exec(htHtml)) !== null) {
149
+ const liRe = /<li[^>]*>([\s\S]*?)<\/li>/gi;
150
+ let liM;
151
+ while ((liM = liRe.exec(olM[1])) !== null) {
152
+ const text = strip(liM[1]);
153
+ if (text) htSteps.push({ name: text.substring(0, 100), text });
154
+ }
155
+ }
156
+ if (htSteps.length > 0) htMethod = 'ordered_list_gutenberg';
157
+ // 2. Any <ol> fallback
158
+ if (htSteps.length === 0) {
159
+ const olAllRe = /<ol[^>]*>([\s\S]*?)<\/ol>/gi;
160
+ let olAllM;
161
+ while ((olAllM = olAllRe.exec(htHtml)) !== null) {
162
+ const liRe2 = /<li[^>]*>([\s\S]*?)<\/li>/gi;
163
+ let liM2;
164
+ while ((liM2 = liRe2.exec(olAllM[1])) !== null) {
165
+ const text = strip(liM2[1]);
166
+ if (text) htSteps.push({ name: text.substring(0, 100), text });
167
+ }
168
+ }
169
+ if (htSteps.length > 0) htMethod = 'ordered_list';
170
+ }
171
+ // 3. Numbered H3/H4 headings
172
+ if (htSteps.length === 0) {
173
+ const hRe = /<h[34][^>]*>([\s\S]*?)<\/h[34]>\s*(?:<p[^>]*>([\s\S]*?)<\/p>)?/gi;
174
+ let hM;
175
+ while ((hM = hRe.exec(htHtml)) !== null) {
176
+ const heading = strip(hM[1]);
177
+ if (/^(?:(?:[ÉéEe]tape|Step)\s*\d+|^\d+[\.\)]\s*)/i.test(heading)) {
178
+ htSteps.push({ name: heading, text: strip(hM[2] || '') || heading });
179
+ }
180
+ }
181
+ if (htSteps.length > 0) htMethod = 'numbered_headings';
182
+ }
183
+ // Extract totalTime (ISO 8601 duration)
184
+ const timeRe = /PT(?:\d+H)?(?:\d+M)?(?:\d+S)?/i;
185
+ const timeMatch = htHtml.match(timeRe);
186
+ const htSchema = {
187
+ '@context': 'https://schema.org', '@type': 'HowTo',
188
+ name: strip(htPost.title?.rendered || ''),
189
+ step: htSteps.map((s, i) => ({ '@type': 'HowToStep', position: i + 1, name: s.name, text: s.text })),
190
+ };
191
+ if (timeMatch) htSchema.totalTime = timeMatch[0];
192
+ // Extract estimatedCost
193
+ const costRe = /(?:cost|price|budget)[:\s]*[$€£]?\s*([\d,.]+)/i;
194
+ const costMatch = htHtml.match(costRe);
195
+ if (costMatch) htSchema.estimatedCost = { '@type': 'MonetaryAmount', value: costMatch[1] };
196
+ result = json({ jsonld: htSchema, step_count: htSteps.length, detection_method: htMethod, has_total_time: !!timeMatch, has_estimated_cost: !!costMatch });
197
+ auditLog({ tool: name, action: 'generate_schema_howto', status: 'success', latency_ms: Date.now() - t0, params: { post_id: args.post_id } });
198
+ return result;
199
+ };
200
+ handlers['wp_generate_schema_localbusiness'] = async (args) => {
201
+ const t0 = Date.now();
202
+ let result;
203
+ const { wpApiCall, auditLog, name } = rt;
204
+ const lbTarget = args.target || 'site';
205
+ const lbSources = [];
206
+ const lbMissing = [];
207
+ const lbData = {};
208
+ // Try ACF fields first (site-level or post-level)
209
+ try {
210
+ if (lbTarget !== 'site' && !isNaN(parseInt(lbTarget))) {
211
+ const lbPost = await wpApiCall(`/posts/${parseInt(lbTarget)}`);
212
+ const acf = lbPost.acf || lbPost.meta || {};
213
+ if (acf.business_name) { lbData.name = acf.business_name; lbSources.push('acf'); }
214
+ if (acf.address) { lbData.address = acf.address; lbSources.push('acf'); }
215
+ if (acf.phone) { lbData.telephone = acf.phone; lbSources.push('acf'); }
216
+ if (acf.email) { lbData.email = acf.email; lbSources.push('acf'); }
217
+ if (acf.opening_hours) { lbData.openingHours = acf.opening_hours; lbSources.push('acf'); }
218
+ }
219
+ } catch (_e) { /* ACF not available */ }
220
+ // Try Yoast Local SEO options
221
+ if (!lbData.name) {
222
+ try {
223
+ const yoastOpts = await wpApiCall('/yoast/v1/configuration', { basePath: '/wp-json' });
224
+ if (yoastOpts?.company_name) { lbData.name = yoastOpts.company_name; lbSources.push('yoast'); }
225
+ } catch (_e) { /* Yoast not available */ }
226
+ }
227
+ // Fallback to WP options
228
+ try {
229
+ const wpOpts = await wpApiCall('/settings', { basePath: '/wp-json/wp/v2' });
230
+ if (!lbData.name && wpOpts.title) { lbData.name = wpOpts.title; lbSources.push('wp_options'); }
231
+ if (!lbData.email && wpOpts.email) { lbData.email = wpOpts.email; lbSources.push('wp_options'); }
232
+ if (!lbData.url) { lbData.url = wpOpts.url || wpOpts.home; lbSources.push('wp_options'); }
233
+ } catch (_e) {
234
+ try { const si = await wpApiCall('/', { basePath: '/wp-json' }); if (!lbData.name && si.name) { lbData.name = si.name; lbSources.push('wp_site_info'); } if (!lbData.url && si.url) { lbData.url = si.url; } } catch (_e2) { /* ignore */ }
235
+ }
236
+ if (!lbData.name) lbMissing.push('name (business_name)');
237
+ if (!lbData.address) lbMissing.push('address');
238
+ if (!lbData.telephone) lbMissing.push('telephone');
239
+ if (!lbData.email) lbMissing.push('email');
240
+ if (!lbData.openingHours) lbMissing.push('openingHours');
241
+ const lbSchema = { '@context': 'https://schema.org', '@type': 'LocalBusiness' };
242
+ if (lbData.name) lbSchema.name = lbData.name;
243
+ if (lbData.url) lbSchema.url = lbData.url;
244
+ if (lbData.telephone) lbSchema.telephone = lbData.telephone;
245
+ if (lbData.email) lbSchema.email = lbData.email;
246
+ if (lbData.address) {
247
+ if (typeof lbData.address === 'string') lbSchema.address = lbData.address;
248
+ else lbSchema.address = { '@type': 'PostalAddress', ...lbData.address };
249
+ }
250
+ if (lbData.openingHours) lbSchema.openingHoursSpecification = lbData.openingHours;
251
+ result = json({ jsonld: lbSchema, sources: [...new Set(lbSources)], fields_missing: lbMissing, recommendations: lbMissing.map(f => `Add ${f} via ACF custom fields or Yoast Local SEO for richer LocalBusiness schema.`) });
252
+ auditLog({ tool: name, action: 'generate_schema_localbusiness', status: 'success', latency_ms: Date.now() - t0, params: { target: lbTarget } });
253
+ return result;
254
+ };
255
+ handlers['wp_generate_schema_breadcrumb'] = async (args) => {
256
+ const t0 = Date.now();
257
+ let result;
258
+ const { wpApiCall, getActiveAuth, auditLog, name } = rt;
259
+ validateInput(args, { post_id: { type: 'number', required: true, min: 1 } });
260
+ const bcPost = await wpApiCall(`/posts/${args.post_id}?_embed=true`).catch(() => null);
261
+ const bcPage = !bcPost ? await wpApiCall(`/pages/${args.post_id}?_embed=true`) : null;
262
+ const bcItem = bcPost || bcPage;
263
+ if (!bcItem) throw new Error(`Post or page ${args.post_id} not found.`);
264
+ const { url: bcSiteUrl } = getActiveAuth();
265
+ const bcItems = [{ name: 'Home', url: bcSiteUrl }];
266
+ const bcType = bcPost ? 'post' : 'page';
267
+ if (bcType === 'post') {
268
+ // Post: Home > Category > Post
269
+ const catIds = bcItem.categories || [];
270
+ if (catIds.length > 0) {
271
+ try {
272
+ const cat = await wpApiCall(`/categories/${catIds[0]}`);
273
+ if (cat.parent && cat.parent > 0) {
274
+ try {
275
+ const parentCat = await wpApiCall(`/categories/${cat.parent}`);
276
+ bcItems.push({ name: strip(parentCat.name), url: parentCat.link });
277
+ } catch (_e) { /* skip parent */ }
278
+ }
279
+ bcItems.push({ name: strip(cat.name), url: cat.link });
280
+ } catch (_e) { /* no category */ }
281
+ }
282
+ } else {
283
+ // Page: Home > Parent pages > Page
284
+ let parentId = bcItem.parent;
285
+ const parentChain = [];
286
+ while (parentId && parentId > 0) {
287
+ try {
288
+ const parentPage = await wpApiCall(`/pages/${parentId}`);
289
+ parentChain.unshift({ name: strip(parentPage.title?.rendered || ''), url: parentPage.link });
290
+ parentId = parentPage.parent;
291
+ } catch (_e) { break; }
292
+ }
293
+ bcItems.push(...parentChain);
294
+ }
295
+ bcItems.push({ name: strip(bcItem.title?.rendered || ''), url: bcItem.link });
296
+ const bcSchema = {
297
+ '@context': 'https://schema.org', '@type': 'BreadcrumbList',
298
+ itemListElement: bcItems.map((item, i) => ({ '@type': 'ListItem', position: i + 1, name: item.name, item: item.url }))
299
+ };
300
+ const bcPath = bcItems.map(i => i.name).join(' > ');
301
+ result = json({ jsonld: bcSchema, path: bcPath, item_count: bcItems.length });
302
+ auditLog({ tool: name, action: 'generate_schema_breadcrumb', status: 'success', latency_ms: Date.now() - t0, params: { post_id: args.post_id } });
303
+ return result;
304
+ };
305
+ handlers['wp_inject_schema'] = async (args) => {
306
+ const t0 = Date.now();
307
+ let result;
308
+ const { wpApiCall, getActiveControls, auditLog, name, handleToolCall } = rt;
309
+ validateInput(args, { post_id: { type: 'number', required: true, min: 1 }, schema_type: { type: 'string', required: true, enum: ['article', 'faq', 'howto', 'localbusiness', 'breadcrumb'] } });
310
+ const { post_id: injPostId, schema_type: injType, override = false, dry_run = false } = args;
311
+ // Enforce read-only UNLESS dry_run
312
+ if (!dry_run && getActiveControls().read_only) {
313
+ throw new Error('Blocked: Server is in READ-ONLY mode (WP_READ_ONLY=true). Tool "wp_inject_schema" is not allowed. Use dry_run=true to preview.');
314
+ }
315
+ // Generate schema via internal call
316
+ const genToolName = `wp_generate_schema_${injType}`;
317
+ const genArgs = injType === 'localbusiness' ? { target: String(injPostId) } : { post_id: injPostId };
318
+ const genResult = await handleToolCall({ params: { name: genToolName, arguments: genArgs } });
319
+ const genData = JSON.parse(genResult.content[0].text);
320
+ const injJsonLd = genData.jsonld;
321
+ if (dry_run) {
322
+ result = json({ dry_run: true, status: 'preview', post_id: injPostId, schema_type: injType, jsonld: injJsonLd, validation_url: `https://validator.schema.org/#url=${encodeURIComponent('')}` });
323
+ auditLog({ tool: name, action: 'inject_schema', status: 'dry_run', latency_ms: Date.now() - t0, params: { post_id: injPostId, schema_type: injType } });
324
+ return result;
325
+ }
326
+ // Check for existing meta
327
+ try {
328
+ const existingMeta = await wpApiCall(`/schema/${injPostId}`, { basePath: '/wp-json/mcp-diagnostics/v1' });
329
+ if (existingMeta && existingMeta.schema && !override) {
330
+ result = { content: [{ type: 'text', text: JSON.stringify({ error: 'Schema already exists. Use override=true to replace.', existing_schema: existingMeta.schema }, null, 2) }], isError: true };
331
+ auditLog({ tool: name, action: 'inject_schema', status: 'blocked_existing', latency_ms: Date.now() - t0, params: { post_id: injPostId, schema_type: injType } });
332
+ return result;
333
+ }
334
+ } catch (_e) { /* No existing meta or endpoint not available, proceed */ }
335
+ // Inject via mu-plugin endpoint or via post meta
336
+ try {
337
+ await wpApiCall(`/schema/${injPostId}`, { method: 'POST', body: JSON.stringify({ schema: JSON.stringify(injJsonLd) }), basePath: '/wp-json/mcp-diagnostics/v1' });
338
+ } catch (_e) {
339
+ // Fallback: update post meta directly via REST
340
+ await wpApiCall(`/posts/${injPostId}`, { method: 'POST', body: JSON.stringify({ meta: { _custom_schema_jsonld: JSON.stringify(injJsonLd) } }) });
341
+ }
342
+ result = json({ status: 'injected', post_id: injPostId, schema_type: injType, jsonld: injJsonLd });
343
+ auditLog({ tool: name, action: 'inject_schema', status: 'success', latency_ms: Date.now() - t0, params: { post_id: injPostId, schema_type: injType, override } });
344
+ return result;
345
+ };
346
+ handlers['wp_validate_schema_live'] = async (args) => {
347
+ const t0 = Date.now();
348
+ let result;
349
+ const { fetch, auditLog, name } = rt;
350
+ validateInput(args, { url: { type: 'string', required: true } });
351
+ const valUrl = args.url;
352
+ const valResp = await fetch(valUrl, { headers: { 'User-Agent': 'WordPress-MCP-Server' }, redirect: 'follow' });
353
+ if (!valResp.ok) throw new Error(`Failed to fetch ${valUrl}: HTTP ${valResp.status}`);
354
+ const valHtml = await valResp.text();
355
+ // Extract all JSON-LD blocks
356
+ const ldRe = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
357
+ let ldMatch;
358
+ const schemas = [];
359
+ while ((ldMatch = ldRe.exec(valHtml)) !== null) {
360
+ try {
361
+ const parsed = JSON.parse(ldMatch[1]);
362
+ schemas.push(parsed);
363
+ } catch (_e) {
364
+ schemas.push({ _raw: ldMatch[1].substring(0, 500), _parse_error: true });
365
+ }
366
+ }
367
+ // Validate each schema
368
+ const requiredFields = {
369
+ Article: ['headline', 'author'],
370
+ FAQPage: ['mainEntity'],
371
+ HowTo: ['step', 'name'],
372
+ LocalBusiness: ['name'],
373
+ BreadcrumbList: ['itemListElement'],
374
+ Product: ['name'],
375
+ WebSite: ['name', 'url'],
376
+ Organization: ['name'],
377
+ };
378
+ const validatedSchemas = schemas.map(s => {
379
+ if (s._parse_error) return { type: 'unknown', valid: false, errors: ['Invalid JSON'], warnings: [] };
380
+ const isGraph = Array.isArray(s['@graph']);
381
+ const items = isGraph ? s['@graph'] : [s];
382
+ return items.map(item => {
383
+ const type = item['@type'] || 'unknown';
384
+ const errors = [];
385
+ const warnings = [];
386
+ // @context is on the parent for @graph items
387
+ if (!item['@context'] && !isGraph) errors.push('Missing @context');
388
+ const reqFields = requiredFields[type] || [];
389
+ reqFields.forEach(f => { if (!item[f]) errors.push(`Missing required field: ${f}`); });
390
+ // Check common issues
391
+ if (item.url && !item.url.startsWith('http')) warnings.push('Relative URL detected in url field');
392
+ if (item.datePublished && isNaN(Date.parse(item.datePublished))) warnings.push('Invalid datePublished format');
393
+ if (item.dateModified && isNaN(Date.parse(item.dateModified))) warnings.push('Invalid dateModified format');
394
+ if (item.image && typeof item.image === 'string' && !item.image.startsWith('http')) warnings.push('Relative URL in image field');
395
+ Object.keys(item).forEach(k => { if (item[k] === '' || item[k] === null) warnings.push(`Empty field: ${k}`); });
396
+ return { type, valid: errors.length === 0, errors, warnings };
397
+ });
398
+ }).flat();
399
+ const richResults = { article: false, faq: false, howto: false, breadcrumb: false, localbusiness: false };
400
+ validatedSchemas.forEach(s => {
401
+ if (s.valid) {
402
+ const t = s.type.toLowerCase();
403
+ if (t === 'article' || t === 'newsarticle' || t === 'blogposting') richResults.article = true;
404
+ if (t === 'faqpage') richResults.faq = true;
405
+ if (t === 'howto') richResults.howto = true;
406
+ if (t === 'breadcrumblist') richResults.breadcrumb = true;
407
+ if (t === 'localbusiness') richResults.localbusiness = true;
408
+ }
409
+ });
410
+ const valRecs = [];
411
+ if (schemas.length === 0) valRecs.push('No JSON-LD schemas found. Add structured data to improve SEO.');
412
+ validatedSchemas.filter(s => !s.valid).forEach(s => valRecs.push(`Fix ${s.type}: ${s.errors.join(', ')}`));
413
+ if (!richResults.article && !richResults.faq && !richResults.howto) valRecs.push('Consider adding Article, FAQ, or HowTo schema for rich results eligibility.');
414
+ result = json({ url: valUrl, schemas_found: validatedSchemas, total_schemas: schemas.length, rich_results_eligible: richResults, validation_source: 'local', recommendations: valRecs });
415
+ auditLog({ tool: name, action: 'validate_schema_live', status: 'success', latency_ms: Date.now() - t0, params: { url: valUrl } });
416
+ return result;
417
+ };