@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,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Layer Registry — runtime detection and tool aggregation.
|
|
3
|
+
*
|
|
4
|
+
* Detects active WordPress plugins (ACF, Elementor, Astra) by inspecting
|
|
5
|
+
* the REST API namespaces at /wp-json/. Aggregates tools from active adapters
|
|
6
|
+
* and respects the WP_DISABLE_PLUGIN_LAYERS governance flag.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const KNOWN_PLUGINS = [
|
|
10
|
+
{ id: 'acf', namespace: 'acf/v3' },
|
|
11
|
+
{ id: 'elementor', namespace: 'elementor/v1' },
|
|
12
|
+
{ id: 'astra', namespace: 'astra/v1' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export class PluginRegistry {
|
|
16
|
+
constructor() {
|
|
17
|
+
/** @type {Map<string, { id: string, namespace: string, version: string|null, tools: object[] }>} */
|
|
18
|
+
this.active = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detect active plugins by calling GET /wp-json/ and inspecting namespaces.
|
|
23
|
+
*
|
|
24
|
+
* @param {Function} apiRequest A function that takes (endpoint, options) and
|
|
25
|
+
* returns parsed JSON. Must support basePath override to hit /wp-json/.
|
|
26
|
+
* @returns {Promise<{ active: string[], disabled_by_env: boolean }>}
|
|
27
|
+
*/
|
|
28
|
+
async initialize(apiRequest) {
|
|
29
|
+
const discovery = await apiRequest('/', { basePath: '/wp-json' });
|
|
30
|
+
const namespaces = discovery?.namespaces || [];
|
|
31
|
+
|
|
32
|
+
for (const known of KNOWN_PLUGINS) {
|
|
33
|
+
if (namespaces.includes(known.namespace)) {
|
|
34
|
+
this.active.set(known.id, {
|
|
35
|
+
id: known.id,
|
|
36
|
+
namespace: known.namespace,
|
|
37
|
+
version: null,
|
|
38
|
+
tools: [],
|
|
39
|
+
});
|
|
40
|
+
console.error(JSON.stringify({
|
|
41
|
+
event: 'plugin_layer_detected',
|
|
42
|
+
plugin: known.id,
|
|
43
|
+
namespaces_found: namespaces.filter(ns => ns.startsWith(known.id)),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return this.getSummary();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return all tools from active adapters.
|
|
53
|
+
* Returns [] if WP_DISABLE_PLUGIN_LAYERS is true.
|
|
54
|
+
*
|
|
55
|
+
* @returns {object[]}
|
|
56
|
+
*/
|
|
57
|
+
getAvailableTools() {
|
|
58
|
+
if (process.env.WP_DISABLE_PLUGIN_LAYERS === 'true') return [];
|
|
59
|
+
return [...this.active.values()].flatMap(a => a.tools ?? []);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check whether a plugin is detected as active.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} pluginId e.g. "acf", "elementor", "astra"
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
isActive(pluginId) {
|
|
69
|
+
return this.active.has(pluginId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Return a summary of the registry state.
|
|
74
|
+
*
|
|
75
|
+
* @returns {{ active: string[], disabled_by_env: boolean }}
|
|
76
|
+
*/
|
|
77
|
+
getSummary() {
|
|
78
|
+
return {
|
|
79
|
+
active: [...this.active.keys()],
|
|
80
|
+
disabled_by_env: process.env.WP_DISABLE_PLUGIN_LAYERS === 'true',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Register an adapter's tools for a detected plugin.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} pluginId
|
|
88
|
+
* @param {object[]} tools
|
|
89
|
+
*/
|
|
90
|
+
registerTools(pluginId, tools) {
|
|
91
|
+
const entry = this.active.get(pluginId);
|
|
92
|
+
if (entry) entry.tools = tools;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress REST API caller with retry, timeout & audit — extracted from index.js (v5.0.0 refactor Step A).
|
|
3
|
+
* index.js still contains its own copy; this will replace it in Step B+.
|
|
4
|
+
*
|
|
5
|
+
* NOTE: wpApiCall depends on runtime state (currentTarget, fetch, log, VERSION)
|
|
6
|
+
* that lives in index.js. For now this file exports the constants and a factory.
|
|
7
|
+
* The actual migration will wire these up in a later step.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── API configuration constants ──
|
|
11
|
+
export const MAX_RETRIES = parseInt(process.env.WP_MCP_MAX_RETRIES || '3', 10);
|
|
12
|
+
export const TIMEOUT_MS = parseInt(process.env.WP_MCP_TIMEOUT || '30000', 10);
|
|
13
|
+
export const RETRY_BASE_DELAY_MS = 1000;
|
|
14
|
+
|
|
15
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a wpApiCall function bound to dependencies.
|
|
19
|
+
* @param {Object} deps - { fetch, getActiveAuth, log, VERSION }
|
|
20
|
+
* @returns {Function} wpApiCall(endpoint, options)
|
|
21
|
+
*/
|
|
22
|
+
export function createWpApiCall({ fetch, getActiveAuth, log, VERSION }) {
|
|
23
|
+
return async function wpApiCall(endpoint, options = {}) {
|
|
24
|
+
const { url: baseUrl, auth: activeAuth } = getActiveAuth();
|
|
25
|
+
const basePath = options.basePath || '/wp-json/wp/v2';
|
|
26
|
+
const url = `${baseUrl}${basePath}${endpoint}`;
|
|
27
|
+
const method = options.method || 'GET';
|
|
28
|
+
const headers = {
|
|
29
|
+
'Authorization': `Basic ${activeAuth}`,
|
|
30
|
+
'User-Agent': `WordPress-MCP-Server/${VERSION}`,
|
|
31
|
+
...options.headers
|
|
32
|
+
};
|
|
33
|
+
if (!options.isMultipart) headers['Content-Type'] = 'application/json';
|
|
34
|
+
|
|
35
|
+
let lastError;
|
|
36
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const tid = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
39
|
+
try {
|
|
40
|
+
log.debug(`${method} ${endpoint} (${attempt}/${MAX_RETRIES})`);
|
|
41
|
+
const t0 = Date.now();
|
|
42
|
+
const fetchOpts = { method, headers, signal: controller.signal };
|
|
43
|
+
if (options.body) fetchOpts.body = options.body;
|
|
44
|
+
const response = await fetch(url, fetchOpts);
|
|
45
|
+
clearTimeout(tid);
|
|
46
|
+
log.debug(`${response.status} in ${Date.now() - t0}ms`);
|
|
47
|
+
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
const errorText = await response.text();
|
|
50
|
+
const sc = response.status;
|
|
51
|
+
if (sc >= 400 && sc < 500 && sc !== 429) {
|
|
52
|
+
const hints = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed' };
|
|
53
|
+
throw new Error(`WP API ${sc}: ${hints[sc] || ''}\n${errorText}`);
|
|
54
|
+
}
|
|
55
|
+
lastError = new Error(`WP API ${sc}: ${errorText}`);
|
|
56
|
+
if (sc === 429) { await sleep(parseInt(response.headers.get('retry-after') || '5', 10) * 1000); continue; }
|
|
57
|
+
if (attempt < MAX_RETRIES) { await sleep(RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1)); continue; }
|
|
58
|
+
throw lastError;
|
|
59
|
+
}
|
|
60
|
+
const ct = response.headers.get('content-type');
|
|
61
|
+
if (!ct || !ct.includes('application/json')) return { success: true, status: response.status };
|
|
62
|
+
const jsonData = await response.json();
|
|
63
|
+
const wpTotal = response.headers.get('x-wp-total');
|
|
64
|
+
if (wpTotal !== null && jsonData !== null && typeof jsonData === 'object') {
|
|
65
|
+
Object.defineProperty(jsonData, '_wpTotal', { value: parseInt(wpTotal, 10), enumerable: false, configurable: true });
|
|
66
|
+
Object.defineProperty(jsonData, '_wpTotalPages', { value: parseInt(response.headers.get('x-wp-totalpages') || '0', 10), enumerable: false, configurable: true });
|
|
67
|
+
}
|
|
68
|
+
return jsonData;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
clearTimeout(tid);
|
|
71
|
+
if (error.name === 'AbortError') lastError = new Error(`Timeout ${TIMEOUT_MS}ms`);
|
|
72
|
+
else if (error.message.includes('WP API 4')) throw error;
|
|
73
|
+
else lastError = error;
|
|
74
|
+
if (attempt < MAX_RETRIES) await sleep(RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw lastError || new Error(`Failed after ${MAX_RETRIES} attempts`);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit logging — extracted from index.js (v5.0.0 refactor Step A).
|
|
3
|
+
* index.js still contains its own copy; this will replace it in Step B+.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const AUDIT_LOG = process.env.WP_AUDIT_LOG !== 'off';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Log an audit record to stderr.
|
|
10
|
+
* @param {Object} entry - Audit entry fields
|
|
11
|
+
* @param {Object} [context] - Runtime context { currentTarget }
|
|
12
|
+
*/
|
|
13
|
+
export function auditLog(entry, context = {}) {
|
|
14
|
+
if (!AUDIT_LOG) return;
|
|
15
|
+
const record = {
|
|
16
|
+
timestamp: new Date().toISOString(),
|
|
17
|
+
tool: entry.tool,
|
|
18
|
+
target: entry.target || null,
|
|
19
|
+
target_type: entry.target_type || null,
|
|
20
|
+
action: entry.action || null,
|
|
21
|
+
status: entry.status,
|
|
22
|
+
latency_ms: entry.latency_ms,
|
|
23
|
+
site: entry.site || context.currentTargetName || 'default',
|
|
24
|
+
params: entry.params || {},
|
|
25
|
+
error: entry.error || null,
|
|
26
|
+
...(entry.effective_controls ? { effective_controls: entry.effective_controls } : {})
|
|
27
|
+
};
|
|
28
|
+
console.error(`[AUDIT] ${JSON.stringify(record)}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sanitize params for audit (remove content/body to keep logs small).
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeParams(args) {
|
|
35
|
+
const safe = { ...args };
|
|
36
|
+
if (safe.content && safe.content.length > 100) safe.content = `[${safe.content.length} chars]`;
|
|
37
|
+
if (safe.body) safe.body = '[binary]';
|
|
38
|
+
return safe;
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime context — holds dependencies that require index.js state.
|
|
3
|
+
* Populated by index.js at startup via initRuntime().
|
|
4
|
+
* Handler modules import `rt` and destructure what they need.
|
|
5
|
+
*
|
|
6
|
+
* Uses Object.defineProperties to preserve getters for mutable state
|
|
7
|
+
* like currentTarget and targets.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const rt = {};
|
|
11
|
+
|
|
12
|
+
export function initRuntime(deps) {
|
|
13
|
+
const descriptors = Object.getOwnPropertyDescriptors(deps);
|
|
14
|
+
Object.defineProperties(rt, descriptors);
|
|
15
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enterprise governance controls — extracted from index.js (v5.0.0 refactor Step A).
|
|
3
|
+
* index.js still contains its own copy; this will replace it in Step B+.
|
|
4
|
+
*
|
|
5
|
+
* NOTE: enforceReadOnly and getActiveControls depend on runtime state (currentTarget).
|
|
6
|
+
* For now these are exported as factories or standalone functions that accept context.
|
|
7
|
+
* The actual migration will wire these up in a later step.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── Governance constants ──
|
|
11
|
+
export const READ_ONLY = process.env.WP_READ_ONLY === 'true';
|
|
12
|
+
export const DRAFT_ONLY = process.env.WP_DRAFT_ONLY === 'true';
|
|
13
|
+
export const DISABLE_DELETE = process.env.WP_DISABLE_DELETE === 'true';
|
|
14
|
+
export const DISABLE_PLUGIN_MANAGEMENT = process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true';
|
|
15
|
+
export const REQUIRE_APPROVAL = process.env.WP_REQUIRE_APPROVAL === 'true';
|
|
16
|
+
export const CONFIRM_DESTRUCTIVE = process.env.WP_CONFIRM_DESTRUCTIVE === 'true';
|
|
17
|
+
export const MAX_CALLS_PER_MINUTE = parseInt(process.env.WP_MAX_CALLS_PER_MINUTE || '0', 10);
|
|
18
|
+
export const ALLOWED_TYPES = process.env.WP_ALLOWED_TYPES ? process.env.WP_ALLOWED_TYPES.split(',').map(s => s.trim()) : null;
|
|
19
|
+
export const ALLOWED_STATUSES = process.env.WP_ALLOWED_STATUSES ? process.env.WP_ALLOWED_STATUSES.split(',').map(s => s.trim()) : null;
|
|
20
|
+
export const AUDIT_LOG_ENABLED = process.env.WP_AUDIT_LOG !== 'off';
|
|
21
|
+
export const VISUAL_STAGING = process.env.WP_VISUAL_STAGING === 'true';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compute active controls by merging global env vars with per-target overrides.
|
|
25
|
+
* @param {Object} [currentTarget] - The current target config (with optional .controls)
|
|
26
|
+
*/
|
|
27
|
+
export function getActiveControls(currentTarget = null) {
|
|
28
|
+
const tc = currentTarget?.controls || {};
|
|
29
|
+
const globalMaxCpm = parseInt(process.env.WP_MAX_CALLS_PER_MINUTE || '0', 10);
|
|
30
|
+
const targetMaxCpm = tc.max_calls_per_minute || 0;
|
|
31
|
+
let effectiveMaxCpm;
|
|
32
|
+
if (globalMaxCpm > 0 && targetMaxCpm > 0) effectiveMaxCpm = Math.min(globalMaxCpm, targetMaxCpm);
|
|
33
|
+
else if (globalMaxCpm > 0) effectiveMaxCpm = globalMaxCpm;
|
|
34
|
+
else if (targetMaxCpm > 0) effectiveMaxCpm = targetMaxCpm;
|
|
35
|
+
else effectiveMaxCpm = 0;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
read_only: process.env.WP_READ_ONLY === 'true' || tc.read_only === true,
|
|
39
|
+
draft_only: process.env.WP_DRAFT_ONLY === 'true' || tc.draft_only === true,
|
|
40
|
+
disable_delete: process.env.WP_DISABLE_DELETE === 'true' || tc.disable_delete === true,
|
|
41
|
+
disable_plugin_management: process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true' || tc.disable_plugin_management === true,
|
|
42
|
+
require_approval: process.env.WP_REQUIRE_APPROVAL === 'true' || tc.require_approval === true,
|
|
43
|
+
confirm_destructive: process.env.WP_CONFIRM_DESTRUCTIVE === 'true' || tc.confirm_destructive === true,
|
|
44
|
+
max_calls_per_minute: effectiveMaxCpm,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute control sources (global vs target vs both).
|
|
50
|
+
* @param {Object} [currentTarget] - The current target config
|
|
51
|
+
*/
|
|
52
|
+
export function getControlSources(currentTarget = null) {
|
|
53
|
+
const tc = currentTarget?.controls || {};
|
|
54
|
+
const src = (g, t) => (g && t) ? 'both' : g ? 'global' : t ? 'target' : 'none';
|
|
55
|
+
const globalMaxCpm = parseInt(process.env.WP_MAX_CALLS_PER_MINUTE || '0', 10);
|
|
56
|
+
const targetMaxCpm = tc.max_calls_per_minute || 0;
|
|
57
|
+
return {
|
|
58
|
+
read_only_source: src(process.env.WP_READ_ONLY === 'true', tc.read_only === true),
|
|
59
|
+
draft_only_source: src(process.env.WP_DRAFT_ONLY === 'true', tc.draft_only === true),
|
|
60
|
+
disable_delete_source: src(process.env.WP_DISABLE_DELETE === 'true', tc.disable_delete === true),
|
|
61
|
+
disable_plugin_management_source: src(process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true', tc.disable_plugin_management === true),
|
|
62
|
+
require_approval_source: src(process.env.WP_REQUIRE_APPROVAL === 'true', tc.require_approval === true),
|
|
63
|
+
confirm_destructive_source: src(process.env.WP_CONFIRM_DESTRUCTIVE === 'true', tc.confirm_destructive === true),
|
|
64
|
+
max_calls_per_minute_source: (globalMaxCpm > 0 && targetMaxCpm > 0) ? 'both' : (globalMaxCpm > 0 ? 'global' : (targetMaxCpm > 0 ? 'target' : 'none')),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate input arguments against a rules object.
|
|
70
|
+
*/
|
|
71
|
+
export function validateInput(args, rules) {
|
|
72
|
+
const errors = [];
|
|
73
|
+
for (const [field, rule] of Object.entries(rules)) {
|
|
74
|
+
const value = args[field];
|
|
75
|
+
if (rule.required && (value === undefined || value === null || value === '')) { errors.push(`"${field}" is required`); continue; }
|
|
76
|
+
if (value === undefined || value === null) continue;
|
|
77
|
+
if (rule.type === 'number') {
|
|
78
|
+
if (typeof value !== 'number' || isNaN(value)) errors.push(`"${field}" must be a number`);
|
|
79
|
+
else { if (rule.min !== undefined && value < rule.min) errors.push(`"${field}" >= ${rule.min}`); if (rule.max !== undefined && value > rule.max) errors.push(`"${field}" <= ${rule.max}`); }
|
|
80
|
+
}
|
|
81
|
+
if (rule.type === 'string' && typeof value !== 'string') errors.push(`"${field}" must be a string`);
|
|
82
|
+
else if (rule.type === 'string' && rule.enum && !rule.enum.includes(value)) errors.push(`"${field}" must be: ${rule.enum.join(', ')}`);
|
|
83
|
+
if (rule.type === 'array' && !Array.isArray(value)) errors.push(`"${field}" must be an array`);
|
|
84
|
+
}
|
|
85
|
+
if (errors.length > 0) throw new Error(`Validation:\n${errors.map(e => ` - ${e}`).join('\n')}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Enforce read-only mode — throws if tool is a write tool and read_only is active.
|
|
90
|
+
* @param {string} toolName
|
|
91
|
+
* @param {Object} controls - Result of getActiveControls()
|
|
92
|
+
*/
|
|
93
|
+
export function enforceReadOnly(toolName, controls) {
|
|
94
|
+
const writeTools = ['wp_create_post', 'wp_update_post', 'wp_delete_post', 'wp_create_page', 'wp_update_page', 'wp_upload_media', 'wp_create_comment', 'wp_create_taxonomy_term', 'wp_update_seo_meta', 'wp_activate_plugin', 'wp_deactivate_plugin', 'wp_restore_revision', 'wp_delete_revision', 'wp_submit_for_review', 'wp_approve_post', 'wp_reject_post', 'wp_create_staging_draft', 'wp_merge_staging_to_live', 'wp_discard_staging_draft', 'wc_list_products', 'wc_get_product', 'wc_list_orders', 'wc_get_order', 'wc_list_customers', 'wc_inventory_alert', 'wc_order_intelligence', 'wc_seo_product_audit', 'wc_suggest_product_links', 'wc_update_product', 'wc_update_stock', 'wc_update_order_status', 'wp_create_template', 'wp_update_template', 'wp_delete_template', 'wp_create_template_part', 'wp_update_template_part', 'wp_delete_template_part', 'wp_update_global_styles', 'wp_create_block_pattern', 'wp_delete_block_pattern', 'wp_create_navigation_menu', 'wp_update_navigation_menu', 'wp_delete_navigation_menu', 'wp_update_widget', 'wp_delete_widget', 'wp_create_user', 'wp_update_user', 'wp_delete_user', 'wp_reset_user_password', 'wp_revoke_application_password', 'wp_bulk_update'];
|
|
95
|
+
if (controls.read_only && writeTools.includes(toolName)) {
|
|
96
|
+
throw new Error(`Blocked: Server is in READ-ONLY mode (WP_READ_ONLY=true). Tool "${toolName}" is not allowed.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utility functions — extracted from index.js (v5.0.0 refactor Step A).
|
|
3
|
+
* index.js still contains its own copies; these will replace them in Step B+.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ── Compact JSON output ──
|
|
7
|
+
const WP_COMPACT_JSON = process.env.WP_COMPACT_JSON !== 'false';
|
|
8
|
+
|
|
9
|
+
export function json(data) {
|
|
10
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, WP_COMPACT_JSON ? 0 : 2) }] };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ── HTML strip ──
|
|
14
|
+
export function strip(html) {
|
|
15
|
+
return (html || '').replace(/<[^>]*>/g, '').trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Pagination helper ──
|
|
19
|
+
export function buildPaginationMeta(total, page, perPage) {
|
|
20
|
+
return {
|
|
21
|
+
page: parseInt(page),
|
|
22
|
+
per_page: parseInt(perPage),
|
|
23
|
+
total: parseInt(total),
|
|
24
|
+
total_pages: Math.ceil(parseInt(total) / parseInt(perPage)),
|
|
25
|
+
has_more: (parseInt(page) * parseInt(perPage)) < parseInt(total),
|
|
26
|
+
next_page: ((parseInt(page) * parseInt(perPage)) < parseInt(total)) ? parseInt(page) + 1 : null
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Gutenberg block validator ──
|
|
31
|
+
export const DEPRECATED_BLOCKS = [
|
|
32
|
+
'core/subhead', 'core/text-columns', 'core/cover-image', 'core/button',
|
|
33
|
+
'core/blocks-gallery', 'core/latest-posts', 'core/categories',
|
|
34
|
+
'core/archives', 'core/calendar', 'core/tag-cloud'
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const SELF_CLOSING_BLOCKS = [
|
|
38
|
+
'core/separator', 'core/spacer', 'core/nextpage', 'core/more', 'core/missing'
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export const NO_NEST_IN_SELF = [
|
|
42
|
+
'core/paragraph', 'core/heading', 'core/code', 'core/preformatted', 'core/verse'
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export function validateBlocks(content, strict = false, fixSuggestions = true) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
const warnings = [];
|
|
48
|
+
const suggestions = [];
|
|
49
|
+
const blockCounts = {};
|
|
50
|
+
const lines = content.split('\n');
|
|
51
|
+
|
|
52
|
+
const openStack = [];
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < lines.length; i++) {
|
|
55
|
+
const line = lines[i];
|
|
56
|
+
const lineNum = i + 1;
|
|
57
|
+
|
|
58
|
+
let match;
|
|
59
|
+
const openRe = /<!--\s*wp:([a-z][a-z0-9-]*\/)?([a-z][a-z0-9-]*)\s*(\{.*?\})?\s*(\/)?-->/g;
|
|
60
|
+
while ((match = openRe.exec(line)) !== null) {
|
|
61
|
+
const ns = match[1] || 'core/';
|
|
62
|
+
const blockName = `${ns}${match[2]}`;
|
|
63
|
+
const attrJson = match[3];
|
|
64
|
+
const selfClosing = match[4] === '/';
|
|
65
|
+
|
|
66
|
+
blockCounts[blockName] = (blockCounts[blockName] || 0) + 1;
|
|
67
|
+
|
|
68
|
+
if (DEPRECATED_BLOCKS.includes(blockName)) {
|
|
69
|
+
warnings.push({ type: 'deprecated_block', block: blockName, message: `Block "${blockName}" is deprecated` });
|
|
70
|
+
if (fixSuggestions) suggestions.push({ issue: `Deprecated block "${blockName}"`, fix: `Replace with its modern equivalent` });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (attrJson) {
|
|
74
|
+
try { JSON.parse(attrJson); } catch (e) {
|
|
75
|
+
errors.push({ type: 'malformed_json', block: blockName, line: lineNum, message: `Malformed JSON attributes in ${blockName}: ${e.message}` });
|
|
76
|
+
if (fixSuggestions) suggestions.push({ issue: `Invalid JSON in ${blockName} at line ${lineNum}`, fix: `Fix JSON syntax: ${attrJson}` });
|
|
77
|
+
}
|
|
78
|
+
} else if (strict && !selfClosing && !SELF_CLOSING_BLOCKS.includes(blockName)) {
|
|
79
|
+
errors.push({ type: 'missing_attributes', block: blockName, line: lineNum, message: `Block "${blockName}" has no attributes (strict mode)` });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (openStack.length > 0 && NO_NEST_IN_SELF.includes(blockName)) {
|
|
83
|
+
const parent = openStack[openStack.length - 1];
|
|
84
|
+
if (NO_NEST_IN_SELF.includes(parent.name)) {
|
|
85
|
+
errors.push({ type: 'invalid_nesting', block: blockName, line: lineNum, message: `"${blockName}" cannot be nested inside "${parent.name}"` });
|
|
86
|
+
if (fixSuggestions) suggestions.push({ issue: `Invalid nesting at line ${lineNum}`, fix: `Close "${parent.name}" before opening "${blockName}"` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!selfClosing) {
|
|
91
|
+
openStack.push({ name: blockName, line: lineNum });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const closeRe = /<!--\s*\/wp:([a-z][a-z0-9-]*\/)?([a-z][a-z0-9-]*)\s*-->/g;
|
|
96
|
+
while ((match = closeRe.exec(line)) !== null) {
|
|
97
|
+
const ns = match[1] || 'core/';
|
|
98
|
+
const closeName = `${ns}${match[2]}`;
|
|
99
|
+
if (openStack.length === 0) {
|
|
100
|
+
errors.push({ type: 'unexpected_close', block: closeName, line: lineNum, message: `Closing comment for "${closeName}" without matching opening` });
|
|
101
|
+
} else {
|
|
102
|
+
const top = openStack[openStack.length - 1];
|
|
103
|
+
if (top.name === closeName) {
|
|
104
|
+
openStack.pop();
|
|
105
|
+
} else {
|
|
106
|
+
errors.push({ type: 'mismatched_close', block: closeName, line: lineNum, message: `Expected closing for "${top.name}" (opened line ${top.line}), found "${closeName}"` });
|
|
107
|
+
if (fixSuggestions) suggestions.push({ issue: `Mismatched block at line ${lineNum}`, fix: `Close "${top.name}" before closing "${closeName}"` });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const open of openStack) {
|
|
114
|
+
errors.push({ type: 'unclosed_block', block: open.name, line: open.line, message: `Block "${open.name}" opened at line ${open.line} is never closed` });
|
|
115
|
+
if (fixSuggestions) suggestions.push({ issue: `Unclosed "${open.name}" at line ${open.line}`, fix: `Add <!-- /wp:${open.name.replace('core/', '')} --> after the block content` });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const htmlContent = content.replace(/<!--.*?-->/gs, '');
|
|
119
|
+
const voidElements = new Set(['area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr']);
|
|
120
|
+
const openTags = [];
|
|
121
|
+
const tagRe = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*\/?>/g;
|
|
122
|
+
let match;
|
|
123
|
+
while ((match = tagRe.exec(htmlContent)) !== null) {
|
|
124
|
+
const fullMatch = match[0];
|
|
125
|
+
const tagName = match[1].toLowerCase();
|
|
126
|
+
if (voidElements.has(tagName) || fullMatch.endsWith('/>')) continue;
|
|
127
|
+
if (fullMatch.startsWith('</')) {
|
|
128
|
+
if (openTags.length > 0 && openTags[openTags.length - 1] === tagName) openTags.pop();
|
|
129
|
+
} else {
|
|
130
|
+
openTags.push(tagName);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const tag of openTags) {
|
|
134
|
+
warnings.push({ type: 'unclosed_html_tag', block: null, message: `HTML tag <${tag}> may not be properly closed` });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const blocksFound = Object.entries(blockCounts).map(([name, count]) => ({ name, count }));
|
|
138
|
+
const totalBlocks = blocksFound.reduce((s, b) => s + b.count, 0);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
valid: errors.length === 0,
|
|
142
|
+
errors,
|
|
143
|
+
warnings,
|
|
144
|
+
suggestions: fixSuggestions ? suggestions : undefined,
|
|
145
|
+
blocks_found: blocksFound,
|
|
146
|
+
summary: `${errors.length} error${errors.length !== 1 ? 's' : ''}, ${warnings.length} warning${warnings.length !== 1 ? 's' : ''} found in ${totalBlocks} block${totalBlocks !== 1 ? 's' : ''}`
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/tools/comments.js — engagement tools (2)
|
|
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_comments', _category: 'engagement', description: 'Use to list comments filtered by post or status (approved/hold/spam). 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 }, post: { type: 'number' }, status: { type: 'string' }, orderby: { type: 'string', default: 'date_gmt' }, order: { type: 'string', default: 'desc' }, search: { type: 'string' }, 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_create_comment', _category: 'engagement', description: 'Use to post a comment or reply on any post. Write — blocked by WP_READ_ONLY.', inputSchema: { type: 'object', properties: { post: { type: 'number' }, content: { type: 'string' }, parent: { type: 'number', default: 0 }, author_name: { type: 'string' }, author_email: { type: 'string' }, status: { type: 'string', default: 'approved' } }, required: ['post', 'content'] }}
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export const handlers = {};
|
|
14
|
+
|
|
15
|
+
handlers['wp_list_comments'] = async (args) => {
|
|
16
|
+
const t0 = Date.now();
|
|
17
|
+
let result;
|
|
18
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
19
|
+
const { per_page = 10, page = 1, post, status, orderby = 'date_gmt', order = 'desc', search, mode = 'full' } = args;
|
|
20
|
+
let ep = `/comments?per_page=${per_page}&page=${page}&orderby=${orderby}&order=${order}`;
|
|
21
|
+
if (post) ep += `&post=${post}`; if (status) ep += `&status=${status}`; if (search) ep += `&search=${encodeURIComponent(search)}`;
|
|
22
|
+
const comments = await wpApiCall(ep);
|
|
23
|
+
const cmtPg = comments._wpTotal !== undefined ? buildPaginationMeta(comments._wpTotal, page, per_page) : undefined;
|
|
24
|
+
if (mode === 'ids_only') {
|
|
25
|
+
result = json({ total: comments.length, page, mode: 'ids_only', ids: comments.map(c => c.id), ...(cmtPg && { pagination: cmtPg }) });
|
|
26
|
+
auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
if (mode === 'summary') {
|
|
30
|
+
result = json({ total: comments.length, page, mode: 'summary', comments: comments.map(c => ({ id: c.id, post: c.post, author_name: c.author_name, date: c.date, status: c.status })), ...(cmtPg && { pagination: cmtPg }) });
|
|
31
|
+
auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
result = json({ total: comments.length, page, comments: comments.map(c => ({ id: c.id, post: c.post, parent: c.parent, author_name: c.author_name, date: c.date, status: c.status, content: strip(c.content.rendered).substring(0, 300), link: c.link })), ...(cmtPg && { pagination: cmtPg }) });
|
|
35
|
+
auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
handlers['wp_create_comment'] = async (args) => {
|
|
39
|
+
const t0 = Date.now();
|
|
40
|
+
let result;
|
|
41
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
42
|
+
validateInput(args, { post: { type: 'number', required: true }, content: { type: 'string', required: true } });
|
|
43
|
+
const { post, content, parent = 0, author_name, author_email, status = 'approved' } = args;
|
|
44
|
+
const data = { post, content, parent, status };
|
|
45
|
+
if (author_name) data.author_name = author_name; if (author_email) data.author_email = author_email;
|
|
46
|
+
const c = await wpApiCall('/comments', { method: 'POST', body: JSON.stringify(data) });
|
|
47
|
+
result = json({ success: true, message: 'Comment created', comment: { id: c.id, post: c.post, status: c.status, content: strip(c.content.rendered).substring(0, 200) } });
|
|
48
|
+
auditLog({ tool: name, target: c.id, target_type: 'comment', action: 'create', status: 'success', latency_ms: Date.now() - t0, params: { post } });
|
|
49
|
+
return result;
|
|
50
|
+
};
|