@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,118 @@
|
|
|
1
|
+
// src/tools/links.js — links tools (2)
|
|
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_analyze_links', _category: 'links', description: 'Use to audit all internal and external links in a single post via HEAD requests. Returns broken/warning/ok status per link. Read-only.',
|
|
10
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number' }, check_broken: { type: 'boolean', default: true, description: 'Check broken internal links via HEAD request' }, timeout_ms: { type: 'number', default: 5000, description: 'Timeout per HEAD request in ms' } }, required: ['post_id'] }},
|
|
11
|
+
{ name: 'wp_suggest_internal_links', _category: 'links', description: 'Use to get scored internal link suggestions for a post based on keyword relevance, categories, and freshness. Read-only. Hint: call wp_get_post with content_format=\'links_only\' first to map existing links.',
|
|
12
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number' }, max_suggestions: { type: 'number', default: 5, description: 'Number of suggestions (1-10)' }, focus_keywords: { type: 'array', items: { type: 'string' }, description: 'Additional keywords to match against' }, exclude_already_linked: { type: 'boolean', default: true, description: 'Exclude posts already linked from the current post' } }, required: ['post_id'] }}
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const handlers = {};
|
|
16
|
+
|
|
17
|
+
handlers['wp_analyze_links'] = async (args) => {
|
|
18
|
+
const t0 = Date.now();
|
|
19
|
+
let result;
|
|
20
|
+
const { wpApiCall, getActiveAuth, auditLog, name, extractInternalLinks, extractExternalLinks, checkLinkStatus } = rt;
|
|
21
|
+
validateInput(args, { post_id: { type: 'number', required: true, min: 1 }, timeout_ms: { type: 'number', min: 100 } });
|
|
22
|
+
const { post_id, check_broken = true, timeout_ms = 5000 } = args;
|
|
23
|
+
const p = await wpApiCall(`/posts/${post_id}`);
|
|
24
|
+
const content = p.content?.rendered || '';
|
|
25
|
+
const postTitle = strip(p.title?.rendered || '');
|
|
26
|
+
const { url: siteUrl } = getActiveAuth();
|
|
27
|
+
const internal_links = extractInternalLinks(content, siteUrl);
|
|
28
|
+
const external_links = extractExternalLinks(content, siteUrl);
|
|
29
|
+
let broken_count = 0, warning_count = 0, unknown_count = 0;
|
|
30
|
+
if (check_broken) {
|
|
31
|
+
const toCheck = internal_links.slice(0, 20);
|
|
32
|
+
for (const link of toCheck) {
|
|
33
|
+
const fullUrl = link.url.startsWith('/') ? `${siteUrl}${link.url}` : link.url;
|
|
34
|
+
const { status, http_code } = await checkLinkStatus(fullUrl, timeout_ms);
|
|
35
|
+
link.status = status;
|
|
36
|
+
link.http_code = http_code;
|
|
37
|
+
if (status === 'broken') broken_count++;
|
|
38
|
+
else if (status === 'warning') warning_count++;
|
|
39
|
+
else if (status === 'unknown') unknown_count++;
|
|
40
|
+
}
|
|
41
|
+
for (let i = 20; i < internal_links.length; i++) {
|
|
42
|
+
internal_links[i].status = 'unchecked';
|
|
43
|
+
internal_links[i].http_code = null;
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
for (const link of internal_links) { link.status = 'unchecked'; link.http_code = null; }
|
|
47
|
+
}
|
|
48
|
+
result = json({
|
|
49
|
+
post_id, post_title: postTitle, internal_links, external_links,
|
|
50
|
+
summary: { total_internal: internal_links.length, total_external: external_links.length, broken_count, warning_count, unknown_count }
|
|
51
|
+
});
|
|
52
|
+
auditLog({ tool: name, target: post_id, target_type: 'post', action: 'analyze_links', status: 'success', latency_ms: Date.now() - t0 });
|
|
53
|
+
return result;
|
|
54
|
+
};
|
|
55
|
+
handlers['wp_suggest_internal_links'] = async (args) => {
|
|
56
|
+
const t0 = Date.now();
|
|
57
|
+
let result;
|
|
58
|
+
const { wpApiCall, getActiveAuth, auditLog, name, extractInternalLinks, extractFocusKeyword, calculateRelevanceScore, suggestAnchorText } = rt;
|
|
59
|
+
validateInput(args, { post_id: { type: 'number', required: true, min: 1 }, max_suggestions: { type: 'number', min: 1, max: 10 } });
|
|
60
|
+
const { post_id, max_suggestions = 5, focus_keywords = [], exclude_already_linked = true } = args;
|
|
61
|
+
const p = await wpApiCall(`/posts/${post_id}`);
|
|
62
|
+
const content = p.content?.rendered || '';
|
|
63
|
+
const postTitle = strip(p.title?.rendered || '');
|
|
64
|
+
const postMeta = p.meta || {};
|
|
65
|
+
const postCategories = p.categories || [];
|
|
66
|
+
const { url: siteUrl } = getActiveAuth();
|
|
67
|
+
|
|
68
|
+
// Step 1 — Collect keywords
|
|
69
|
+
const seoKeyword = extractFocusKeyword(postMeta);
|
|
70
|
+
let keywords = [...(Array.isArray(focus_keywords) ? focus_keywords : [])];
|
|
71
|
+
if (seoKeyword) keywords.unshift(seoKeyword);
|
|
72
|
+
keywords = [...new Set(keywords.map(k => k.toLowerCase()))];
|
|
73
|
+
if (keywords.length === 0) {
|
|
74
|
+
keywords = postTitle.split(/\s+/).slice(0, 5).filter(w => w.length > 2).map(w => w.toLowerCase());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Already-linked URLs
|
|
78
|
+
const linkedUrls = siteUrl ? extractInternalLinks(content, siteUrl).map(l => l.url) : [];
|
|
79
|
+
|
|
80
|
+
// Step 2 — Search for candidates (max 3 keyword searches)
|
|
81
|
+
const candidateMap = new Map();
|
|
82
|
+
for (const kw of keywords.slice(0, 3)) {
|
|
83
|
+
try {
|
|
84
|
+
const results = await wpApiCall(`/posts?search=${encodeURIComponent(kw)}&per_page=10&status=publish`);
|
|
85
|
+
if (Array.isArray(results)) {
|
|
86
|
+
for (const r of results) {
|
|
87
|
+
if (r.id !== post_id && !candidateMap.has(r.id)) candidateMap.set(r.id, r);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch { /* search failed */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Step 3 — Score each candidate
|
|
94
|
+
const currentPostData = { id: post_id, categories: postCategories, linkedUrls: exclude_already_linked ? linkedUrls : [] };
|
|
95
|
+
const scored = [];
|
|
96
|
+
let excluded_count = 0;
|
|
97
|
+
|
|
98
|
+
for (const [, cand] of candidateMap) {
|
|
99
|
+
const candTitle = typeof cand.title === 'string' ? cand.title : (cand.title?.rendered || '');
|
|
100
|
+
const { total, breakdown } = calculateRelevanceScore(
|
|
101
|
+
{ id: cand.id, title: candTitle, date: cand.date, categories: cand.categories || [], meta: cand.meta || {}, link: cand.link || '' },
|
|
102
|
+
currentPostData, keywords
|
|
103
|
+
);
|
|
104
|
+
if (total === -999) { excluded_count++; continue; }
|
|
105
|
+
scored.push({
|
|
106
|
+
target_post_id: cand.id, target_title: strip(candTitle), target_url: cand.link || '',
|
|
107
|
+
anchor_text: suggestAnchorText({ meta: cand.meta || {}, title: candTitle }),
|
|
108
|
+
relevance_score: total, score_breakdown: breakdown,
|
|
109
|
+
already_linked: linkedUrls.some(u => u === cand.link)
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
scored.sort((a, b) => b.relevance_score - a.relevance_score);
|
|
114
|
+
const suggestions = scored.slice(0, Math.min(max_suggestions, 10));
|
|
115
|
+
result = json({ post_id, post_title: postTitle, keywords_used: keywords, suggestions, excluded_already_linked: excluded_count });
|
|
116
|
+
auditLog({ tool: name, target: post_id, target_type: 'post', action: 'suggest_links', status: 'success', latency_ms: Date.now() - t0 });
|
|
117
|
+
return result;
|
|
118
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/tools/media.js — media tools (3)
|
|
2
|
+
// Definitions + handlers (v5.0.0 refactor Step B+C)
|
|
3
|
+
|
|
4
|
+
import { json, strip, buildPaginationMeta } 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_list_media', _category: 'media', description: 'Use to browse media library by type (image/video/audio). Returns id, URL, alt_text, dimensions. Read-only. Hint: use mode=\'summary\' for listings, \'ids_only\' for batch ops.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, media_type: { type: 'string' }, search: { type: 'string' }, orderby: { type: 'string', default: 'date' }, order: { type: 'string', default: 'desc' }, mode: { type: 'string', enum: ['full', 'summary', 'ids_only'], default: 'full', description: 'full=all fields, summary=key fields only, ids_only=flat ID array' } }}},
|
|
10
|
+
{ name: 'wp_get_media', _category: 'media', description: 'Use to get full media details with all available sizes. Read-only.', inputSchema: { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] }},
|
|
11
|
+
{ name: 'wp_upload_media', _category: 'media', description: 'Use to upload a file from a public URL to the media library. Set alt_text for image SEO. Write — blocked by WP_READ_ONLY.', inputSchema: { type: 'object', properties: { url: { type: 'string' }, filename: { type: 'string' }, title: { type: 'string' }, alt_text: { type: 'string' }, caption: { type: 'string' }, description: { type: 'string' }, post_id: { type: 'number' } }, required: ['url'] }}
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const handlers = {};
|
|
15
|
+
|
|
16
|
+
handlers['wp_list_media'] = async (args) => {
|
|
17
|
+
const t0 = Date.now();
|
|
18
|
+
let result;
|
|
19
|
+
const { wpApiCall, auditLog, name, ORDERS, MEDIA_TYPES } = rt;
|
|
20
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 }, media_type: { type: 'string', enum: MEDIA_TYPES }, order: { type: 'string', enum: ORDERS }, mode: { type: 'string', enum: ['full', 'summary', 'ids_only'] } });
|
|
21
|
+
const { per_page = 10, page = 1, media_type, search, orderby = 'date', order = 'desc', mode = 'full' } = args;
|
|
22
|
+
let ep = `/media?per_page=${per_page}&page=${page}&orderby=${orderby}&order=${order}`;
|
|
23
|
+
if (media_type) ep += `&media_type=${media_type}`; if (search) ep += `&search=${encodeURIComponent(search)}`;
|
|
24
|
+
const media = await wpApiCall(ep);
|
|
25
|
+
const mediaPg = media._wpTotal !== undefined ? buildPaginationMeta(media._wpTotal, page, per_page) : undefined;
|
|
26
|
+
if (mode === 'ids_only') {
|
|
27
|
+
result = json({ total: media.length, page, mode: 'ids_only', ids: media.map(m => m.id), ...(mediaPg && { pagination: mediaPg }) });
|
|
28
|
+
auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
if (mode === 'summary') {
|
|
32
|
+
result = json({ total: media.length, page, mode: 'summary', media: media.map(m => ({ id: m.id, title: m.title.rendered, mime_type: m.mime_type, source_url: m.source_url, alt_text: m.alt_text })), ...(mediaPg && { pagination: mediaPg }) });
|
|
33
|
+
auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
result = json({ total: media.length, page, media: media.map(m => ({ id: m.id, title: m.title.rendered, date: m.date, mime_type: m.mime_type, source_url: m.source_url, alt_text: m.alt_text, width: m.media_details?.width, height: m.media_details?.height })), ...(mediaPg && { pagination: mediaPg }) });
|
|
37
|
+
auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
handlers['wp_get_media'] = async (args) => {
|
|
41
|
+
const t0 = Date.now();
|
|
42
|
+
let result;
|
|
43
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
44
|
+
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
45
|
+
const m = await wpApiCall(`/media/${args.id}`);
|
|
46
|
+
const sizes = m.media_details?.sizes || {};
|
|
47
|
+
result = json({ id: m.id, title: m.title.rendered, date: m.date, mime_type: m.mime_type, source_url: m.source_url, alt_text: m.alt_text, caption: strip(m.caption?.rendered), width: m.media_details?.width, height: m.media_details?.height, file: m.media_details?.file, sizes: Object.fromEntries(Object.entries(sizes).map(([k, v]) => [k, { url: v.source_url, width: v.width, height: v.height }])) });
|
|
48
|
+
auditLog({ tool: name, target: args.id, target_type: 'media', action: 'read', status: 'success', latency_ms: Date.now() - t0 });
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
handlers['wp_upload_media'] = async (args) => {
|
|
52
|
+
const t0 = Date.now();
|
|
53
|
+
let result;
|
|
54
|
+
const { wpApiCall, TIMEOUT_MS, fetch, auditLog, name } = rt;
|
|
55
|
+
validateInput(args, { url: { type: 'string', required: true } });
|
|
56
|
+
const { url: fileUrl, filename, title, alt_text, caption, description, post_id } = args;
|
|
57
|
+
const dlResp = await fetch(fileUrl, { timeout: TIMEOUT_MS });
|
|
58
|
+
if (!dlResp.ok) throw new Error(`Download failed: ${dlResp.status}`);
|
|
59
|
+
const fileBuffer = await dlResp.buffer();
|
|
60
|
+
const contentType = dlResp.headers.get('content-type') || 'application/octet-stream';
|
|
61
|
+
const fname = filename || fileUrl.split('/').pop().split('?')[0] || 'upload';
|
|
62
|
+
let ep = '/media'; if (post_id) ep += `?post=${post_id}`;
|
|
63
|
+
const uploaded = await wpApiCall(ep, { method: 'POST', body: fileBuffer, isMultipart: true, headers: { 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${fname}"` } });
|
|
64
|
+
if (title || alt_text || caption || description) {
|
|
65
|
+
const md = {}; if (title) md.title = title; if (alt_text) md.alt_text = alt_text; if (caption) md.caption = caption; if (description) md.description = description;
|
|
66
|
+
await wpApiCall(`/media/${uploaded.id}`, { method: 'POST', body: JSON.stringify(md) });
|
|
67
|
+
}
|
|
68
|
+
result = json({ success: true, message: `Uploaded: ${fname}`, media: { id: uploaded.id, source_url: uploaded.source_url, mime_type: uploaded.mime_type } });
|
|
69
|
+
auditLog({ tool: name, target: uploaded.id, target_type: 'media', action: 'upload', status: 'success', latency_ms: Date.now() - t0, params: { filename: fname } });
|
|
70
|
+
return result;
|
|
71
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// src/tools/performance.js — performance tools (6)
|
|
2
|
+
// Definitions + handlers (v5.0.0 refactor Step B+C)
|
|
3
|
+
|
|
4
|
+
import { json } 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_audit_page_speed', _category: 'performance', description: 'Use to run a Google PageSpeed Insights audit. Returns Core Web Vitals (LCP, CLS, INP, FCP, TTFB), overall score, and optimization opportunities sorted by impact. Requires PAGESPEED_API_KEY env var. Read-only.',
|
|
10
|
+
inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'URL to audit (full URL)' }, strategy: { type: 'string', enum: ['mobile', 'desktop'], description: 'Test strategy (default mobile)' } }, required: ['url'] }},
|
|
11
|
+
{ name: 'wp_find_render_blocking_resources', _category: 'performance', description: 'Use to detect render-blocking CSS and JS in the <head> of a page. Fetches the page HTML and identifies <link rel="stylesheet"> and <script> tags without defer/async. Read-only.',
|
|
12
|
+
inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'URL to analyze (defaults to site homepage)' } }}},
|
|
13
|
+
{ name: 'wp_audit_image_optimization', _category: 'performance', description: 'Use to audit media library images for optimization issues: non-WebP format, large file size (>100KB), missing alt text. Read-only.',
|
|
14
|
+
inputSchema: { type: 'object', properties: { per_page: { type: 'number', description: 'default 50, max 100' }, min_size_kb: { type: 'number', description: 'default 100KB' } }}},
|
|
15
|
+
{ name: 'wp_check_caching_status', _category: 'performance', description: 'Use to check if a caching plugin is active (WP Rocket, W3 Total Cache, LiteSpeed, WP Super Cache, Autoptimize) and detect cache-related HTTP headers on the homepage. Read-only.',
|
|
16
|
+
inputSchema: { type: 'object', properties: {} }},
|
|
17
|
+
{ name: 'wp_audit_database_bloat', _category: 'performance', description: 'Use to audit database for bloat: revision count, expired transients, auto-drafts, spam/trash, orphan postmeta, table sizes. Requires mcp-diagnostics companion mu-plugin. Read-only.',
|
|
18
|
+
inputSchema: { type: 'object', properties: {} }},
|
|
19
|
+
{ name: 'wp_get_plugin_performance_impact', _category: 'performance', description: 'Use to estimate the performance impact of active plugins. Cross-references active plugins with a known impact database (~50 plugins). Returns plugins ranked by estimated load time impact. Read-only.',
|
|
20
|
+
inputSchema: { type: 'object', properties: {} }}
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const handlers = {};
|
|
24
|
+
|
|
25
|
+
handlers['wp_audit_page_speed'] = async (args) => {
|
|
26
|
+
const t0 = Date.now();
|
|
27
|
+
let result;
|
|
28
|
+
const { fetch, auditLog, name } = rt;
|
|
29
|
+
validateInput(args, { url: { type: 'string', required: true }, strategy: { type: 'string', enum: ['mobile', 'desktop'] } });
|
|
30
|
+
const { url: pageUrl, strategy = 'mobile' } = args;
|
|
31
|
+
const apiKey = process.env.PAGESPEED_API_KEY;
|
|
32
|
+
if (!apiKey) throw new Error('PAGESPEED_API_KEY environment variable is required. Get a free key at https://developers.google.com/speed/docs/insights/v5/get-started');
|
|
33
|
+
const psiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(pageUrl)}&strategy=${strategy}&key=${apiKey}`;
|
|
34
|
+
const psiResp = await fetch(psiUrl, { headers: { 'User-Agent': 'WordPress-MCP-Server' } });
|
|
35
|
+
if (!psiResp.ok) {
|
|
36
|
+
const errText = await psiResp.text();
|
|
37
|
+
throw new Error(`PageSpeed API ${psiResp.status}: ${errText.substring(0, 300)}`);
|
|
38
|
+
}
|
|
39
|
+
const psi = await psiResp.json();
|
|
40
|
+
const lhr = psi.lighthouseResult || {};
|
|
41
|
+
const lhrAudits = lhr.audits || {};
|
|
42
|
+
const lhrCategories = lhr.categories || {};
|
|
43
|
+
const perfScore = Math.round((lhrCategories.performance?.score || 0) * 100);
|
|
44
|
+
const cwvMetrics = {
|
|
45
|
+
lcp: lhrAudits['largest-contentful-paint']?.displayValue || 'N/A',
|
|
46
|
+
lcp_score: lhrAudits['largest-contentful-paint']?.score,
|
|
47
|
+
cls: lhrAudits['cumulative-layout-shift']?.displayValue || 'N/A',
|
|
48
|
+
cls_score: lhrAudits['cumulative-layout-shift']?.score,
|
|
49
|
+
inp: lhrAudits['interaction-to-next-paint']?.displayValue || lhrAudits['max-potential-fid']?.displayValue || 'N/A',
|
|
50
|
+
inp_score: lhrAudits['interaction-to-next-paint']?.score || lhrAudits['max-potential-fid']?.score,
|
|
51
|
+
fcp: lhrAudits['first-contentful-paint']?.displayValue || 'N/A',
|
|
52
|
+
fcp_score: lhrAudits['first-contentful-paint']?.score,
|
|
53
|
+
ttfb: lhrAudits['server-response-time']?.displayValue || 'N/A',
|
|
54
|
+
ttfb_score: lhrAudits['server-response-time']?.score,
|
|
55
|
+
speed_index: lhrAudits['speed-index']?.displayValue || 'N/A',
|
|
56
|
+
tbt: lhrAudits['total-blocking-time']?.displayValue || 'N/A'
|
|
57
|
+
};
|
|
58
|
+
const opportunities = Object.values(lhrAudits)
|
|
59
|
+
.filter(a => a.details?.type === 'opportunity' && a.details?.overallSavingsMs > 0)
|
|
60
|
+
.sort((a, b) => (b.details.overallSavingsMs || 0) - (a.details.overallSavingsMs || 0))
|
|
61
|
+
.map(a => ({ title: a.title, savings_ms: a.details.overallSavingsMs, savings_bytes: a.details.overallSavingsBytes || 0, description: a.description?.substring(0, 200) }));
|
|
62
|
+
result = json({ url: pageUrl, strategy, score: perfScore, metrics: cwvMetrics, opportunities_count: opportunities.length, opportunities });
|
|
63
|
+
auditLog({ tool: name, action: 'audit', status: 'success', latency_ms: Date.now() - t0, params: { url: pageUrl, strategy } });
|
|
64
|
+
return result;
|
|
65
|
+
};
|
|
66
|
+
handlers['wp_find_render_blocking_resources'] = async (args) => {
|
|
67
|
+
const t0 = Date.now();
|
|
68
|
+
let result;
|
|
69
|
+
const { getActiveAuth, fetch, auditLog, name } = rt;
|
|
70
|
+
const { url: rbUrl } = args;
|
|
71
|
+
const { url: siteUrl } = getActiveAuth();
|
|
72
|
+
const targetUrl = rbUrl || siteUrl;
|
|
73
|
+
const htmlResp = await fetch(targetUrl, { headers: { 'User-Agent': 'WordPress-MCP-Server' }, redirect: 'follow' });
|
|
74
|
+
if (!htmlResp.ok) throw new Error(`Failed to fetch ${targetUrl}: HTTP ${htmlResp.status}`);
|
|
75
|
+
const html = await htmlResp.text();
|
|
76
|
+
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
77
|
+
const headHtml = headMatch ? headMatch[1] : '';
|
|
78
|
+
const blocking = [];
|
|
79
|
+
const cssRegex = /<link[^>]*rel=["']stylesheet["'][^>]*>/gi;
|
|
80
|
+
let cssMatch;
|
|
81
|
+
while ((cssMatch = cssRegex.exec(headHtml)) !== null) {
|
|
82
|
+
const tag = cssMatch[0];
|
|
83
|
+
if (/media=["']print["']/i.test(tag)) continue;
|
|
84
|
+
if (/disabled/i.test(tag)) continue;
|
|
85
|
+
const hrefMatch = tag.match(/href=["']([^"']+)["']/i);
|
|
86
|
+
if (hrefMatch) blocking.push({ type: 'css', url: hrefMatch[1], tag: tag.substring(0, 150) });
|
|
87
|
+
}
|
|
88
|
+
const jsRegex = /<script[^>]*src=["'][^"']+["'][^>]*>/gi;
|
|
89
|
+
let jsMatch;
|
|
90
|
+
while ((jsMatch = jsRegex.exec(headHtml)) !== null) {
|
|
91
|
+
const tag = jsMatch[0];
|
|
92
|
+
if (/\b(defer|async)\b/i.test(tag)) continue;
|
|
93
|
+
if (/type=["']module["']/i.test(tag)) continue;
|
|
94
|
+
const srcMatch = tag.match(/src=["']([^"']+)["']/i);
|
|
95
|
+
if (srcMatch) blocking.push({ type: 'js', url: srcMatch[1], tag: tag.substring(0, 150) });
|
|
96
|
+
}
|
|
97
|
+
result = json({ url: targetUrl, total_blocking: blocking.length, blocking_css: blocking.filter(b => b.type === 'css').length, blocking_js: blocking.filter(b => b.type === 'js').length, resources: blocking });
|
|
98
|
+
auditLog({ tool: name, action: 'audit', status: 'success', latency_ms: Date.now() - t0, params: { url: targetUrl } });
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
handlers['wp_audit_image_optimization'] = async (args) => {
|
|
102
|
+
const t0 = Date.now();
|
|
103
|
+
let result;
|
|
104
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
105
|
+
const { per_page: imgPerPage = 50, min_size_kb = 100 } = args;
|
|
106
|
+
validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, min_size_kb: { type: 'number', min: 1 } });
|
|
107
|
+
const mediaItems = await wpApiCall(`/media?per_page=${Math.min(imgPerPage, 100)}&media_type=image&orderby=date&order=desc`);
|
|
108
|
+
const imgIssues = [];
|
|
109
|
+
for (const m of mediaItems) {
|
|
110
|
+
const problems = [];
|
|
111
|
+
const src = m.source_url || '';
|
|
112
|
+
const mime = m.mime_type || '';
|
|
113
|
+
const alt = m.alt_text || '';
|
|
114
|
+
const fileSize = m.media_details?.filesize || 0;
|
|
115
|
+
const sizeKb = Math.round(fileSize / 1024);
|
|
116
|
+
if (mime && !mime.includes('webp') && !mime.includes('avif') && !mime.includes('svg')) {
|
|
117
|
+
problems.push({ issue: 'not_modern_format', detail: `Format: ${mime}. Consider converting to WebP.` });
|
|
118
|
+
}
|
|
119
|
+
if (sizeKb > min_size_kb) {
|
|
120
|
+
problems.push({ issue: 'large_file', detail: `${sizeKb}KB exceeds ${min_size_kb}KB threshold.` });
|
|
121
|
+
}
|
|
122
|
+
if (!alt || alt.trim().length === 0) {
|
|
123
|
+
problems.push({ issue: 'missing_alt_text', detail: 'No alt text set. Bad for SEO and accessibility.' });
|
|
124
|
+
} else if (alt.trim().length < 5) {
|
|
125
|
+
problems.push({ issue: 'short_alt_text', detail: `Alt text too short (${alt.trim().length} chars).` });
|
|
126
|
+
}
|
|
127
|
+
if (problems.length > 0) {
|
|
128
|
+
imgIssues.push({ id: m.id, filename: src.split('/').pop(), url: src, size_kb: sizeKb, mime_type: mime, alt_text: alt || null, problems, priority: problems.length >= 3 ? 'high' : problems.length === 2 ? 'medium' : 'low' });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
imgIssues.sort((a, b) => { const p = { high: 3, medium: 2, low: 1 }; return p[b.priority] - p[a.priority]; });
|
|
132
|
+
result = json({ total_audited: mediaItems.length, issues_found: imgIssues.length, by_priority: { high: imgIssues.filter(i => i.priority === 'high').length, medium: imgIssues.filter(i => i.priority === 'medium').length, low: imgIssues.filter(i => i.priority === 'low').length }, images: imgIssues });
|
|
133
|
+
auditLog({ tool: name, action: 'audit', status: 'success', latency_ms: Date.now() - t0, params: { per_page: imgPerPage, min_size_kb } });
|
|
134
|
+
return result;
|
|
135
|
+
};
|
|
136
|
+
handlers['wp_check_caching_status'] = async (args) => {
|
|
137
|
+
const t0 = Date.now();
|
|
138
|
+
let result;
|
|
139
|
+
const { wpApiCall, getActiveAuth, fetch, auditLog, name } = rt;
|
|
140
|
+
const allPlugins = await wpApiCall('/plugins');
|
|
141
|
+
const knownCachePlugins = {
|
|
142
|
+
'wp-rocket/wp-rocket.php': 'WP Rocket',
|
|
143
|
+
'w3-total-cache/w3-total-cache.php': 'W3 Total Cache',
|
|
144
|
+
'litespeed-cache/litespeed-cache.php': 'LiteSpeed Cache',
|
|
145
|
+
'wp-super-cache/wp-cache.php': 'WP Super Cache',
|
|
146
|
+
'autoptimize/autoptimize.php': 'Autoptimize',
|
|
147
|
+
'wp-fastest-cache/wpFastestCache.php': 'WP Fastest Cache',
|
|
148
|
+
'breeze/breeze.php': 'Breeze',
|
|
149
|
+
'sg-cachepress/sg-cachepress.php': 'SG Optimizer',
|
|
150
|
+
'nitropack/main.php': 'NitroPack',
|
|
151
|
+
'cache-enabler/cache-enabler.php': 'Cache Enabler'
|
|
152
|
+
};
|
|
153
|
+
const detectedCachePlugins = [];
|
|
154
|
+
if (Array.isArray(allPlugins)) {
|
|
155
|
+
for (const p of allPlugins) {
|
|
156
|
+
const slug = p.plugin || '';
|
|
157
|
+
if (knownCachePlugins[slug]) {
|
|
158
|
+
detectedCachePlugins.push({ plugin: slug, name: knownCachePlugins[slug], version: p.version, status: p.status });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const { url: homeUrl } = getActiveAuth();
|
|
163
|
+
let cacheHeaders = {};
|
|
164
|
+
try {
|
|
165
|
+
const headResp = await fetch(homeUrl, { method: 'HEAD', headers: { 'User-Agent': 'WordPress-MCP-Server' }, redirect: 'follow' });
|
|
166
|
+
const headerNames = ['x-cache', 'cf-cache-status', 'x-varnish', 'x-proxy-cache', 'x-litespeed-cache', 'x-wp-cf-super-cache', 'cache-control', 'x-fastcgi-cache', 'x-sucuri-cache', 'server-timing', 'x-kinsta-cache', 'x-cache-enabled'];
|
|
167
|
+
for (const h of headerNames) {
|
|
168
|
+
const val = headResp.headers.get(h);
|
|
169
|
+
if (val) cacheHeaders[h] = val;
|
|
170
|
+
}
|
|
171
|
+
} catch (e) {
|
|
172
|
+
cacheHeaders = { error: `Could not fetch homepage headers: ${e.message}` };
|
|
173
|
+
}
|
|
174
|
+
const hasActiveCache = detectedCachePlugins.some(p => p.status === 'active');
|
|
175
|
+
const hasCacheHeaders = Object.keys(cacheHeaders).length > 0 && !cacheHeaders.error;
|
|
176
|
+
result = json({ caching_detected: hasActiveCache || hasCacheHeaders, plugins: detectedCachePlugins, http_headers: cacheHeaders, recommendation: !hasActiveCache && !hasCacheHeaders ? 'No caching detected. Install a caching plugin (WP Rocket, LiteSpeed Cache, or WP Super Cache) for significant performance improvement.' : null });
|
|
177
|
+
auditLog({ tool: name, action: 'check', status: 'success', latency_ms: Date.now() - t0 });
|
|
178
|
+
return result;
|
|
179
|
+
};
|
|
180
|
+
handlers['wp_audit_database_bloat'] = async (args) => {
|
|
181
|
+
const t0 = Date.now();
|
|
182
|
+
let result;
|
|
183
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
184
|
+
const bloatData = await wpApiCall('/database-bloat', { basePath: '/wp-json/mcp-diagnostics/v1' });
|
|
185
|
+
result = json({ revisions: bloatData.revisions, auto_drafts: bloatData.auto_drafts, trashed_posts: bloatData.trashed_posts, spam_comments: bloatData.spam_comments, trashed_comments: bloatData.trashed_comments, transients_total: bloatData.transients_total, transients_expired: bloatData.transients_expired, orphan_postmeta: bloatData.orphan_postmeta, database_size_mb: bloatData.database_size_mb, tables: bloatData.tables || [], recommendations: bloatData.recommendations || [] });
|
|
186
|
+
auditLog({ tool: name, action: 'audit', status: 'success', latency_ms: Date.now() - t0 });
|
|
187
|
+
return result;
|
|
188
|
+
};
|
|
189
|
+
handlers['wp_get_plugin_performance_impact'] = async (args) => {
|
|
190
|
+
const t0 = Date.now();
|
|
191
|
+
let result;
|
|
192
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
193
|
+
const perfPlugins = await wpApiCall('/plugins');
|
|
194
|
+
const activePerfPlugins = Array.isArray(perfPlugins) ? perfPlugins.filter(p => p.status === 'active') : [];
|
|
195
|
+
let perfDb;
|
|
196
|
+
try {
|
|
197
|
+
const { readFileSync } = await import('fs');
|
|
198
|
+
const { fileURLToPath } = await import('url');
|
|
199
|
+
const { dirname, join } = await import('path');
|
|
200
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
201
|
+
const currentDir = dirname(currentFile);
|
|
202
|
+
perfDb = JSON.parse(readFileSync(join(currentDir, '..', 'data', 'plugin-performance-data.json'), 'utf8'));
|
|
203
|
+
} catch (e) {
|
|
204
|
+
throw new Error(`Could not load plugin performance database: ${e.message}`);
|
|
205
|
+
}
|
|
206
|
+
const pluginDb = perfDb.plugins || {};
|
|
207
|
+
const perfReport = activePerfPlugins.map(p => {
|
|
208
|
+
const slug = p.plugin || '';
|
|
209
|
+
const known = pluginDb[slug];
|
|
210
|
+
return { plugin: slug, name: p.name || slug, version: p.version, impact: known ? known.impact : null, impact_label: known ? (known.impact <= -2 ? 'positive' : known.impact <= 0 ? 'neutral' : known.impact <= 2 ? 'low' : known.impact <= 3 ? 'moderate' : 'high') : 'unknown', category: known?.category || 'unknown', notes: known?.notes || null };
|
|
211
|
+
});
|
|
212
|
+
perfReport.sort((a, b) => (b.impact || 0) - (a.impact || 0));
|
|
213
|
+
const heavyPlugins = perfReport.filter(r => r.impact !== null && r.impact >= 3);
|
|
214
|
+
const positivePlugins = perfReport.filter(r => r.impact !== null && r.impact < 0);
|
|
215
|
+
const unknownPlugins = perfReport.filter(r => r.impact === null);
|
|
216
|
+
result = json({ total_active: perfReport.length, heavy_count: heavyPlugins.length, positive_count: positivePlugins.length, unknown_count: unknownPlugins.length, plugins: perfReport, summary: heavyPlugins.length > 0 ? `${heavyPlugins.length} plugin(s) with high performance impact: ${heavyPlugins.map(p => p.name).join(', ')}` : 'No high-impact plugins detected.' });
|
|
217
|
+
auditLog({ tool: name, action: 'audit', status: 'success', latency_ms: Date.now() - t0 });
|
|
218
|
+
return result;
|
|
219
|
+
};
|