@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
|
@@ -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
|
+
};
|