@adsim/wordpress-mcp-server 1.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.env.example +8 -0
  2. package/.github/workflows/ci.yml +20 -0
  3. package/LICENSE +1 -1
  4. package/README.md +596 -135
  5. package/index.js +1367 -0
  6. package/package.json +21 -33
  7. package/src/auth/bearer.js +72 -0
  8. package/src/transport/http.js +264 -0
  9. package/tests/helpers/mockWpRequest.js +135 -0
  10. package/tests/unit/governance.test.js +260 -0
  11. package/tests/unit/tools/comments.test.js +170 -0
  12. package/tests/unit/tools/media.test.js +279 -0
  13. package/tests/unit/tools/pages.test.js +222 -0
  14. package/tests/unit/tools/plugins.test.js +268 -0
  15. package/tests/unit/tools/posts.test.js +310 -0
  16. package/tests/unit/tools/revisions.test.js +299 -0
  17. package/tests/unit/tools/search.test.js +190 -0
  18. package/tests/unit/tools/seo.test.js +248 -0
  19. package/tests/unit/tools/site.test.js +133 -0
  20. package/tests/unit/tools/taxonomies.test.js +220 -0
  21. package/tests/unit/tools/themes.test.js +163 -0
  22. package/tests/unit/tools/users.test.js +113 -0
  23. package/tests/unit/transport/http.test.js +300 -0
  24. package/vitest.config.js +12 -0
  25. package/dist/constants.d.ts +0 -13
  26. package/dist/constants.d.ts.map +0 -1
  27. package/dist/constants.js +0 -10
  28. package/dist/constants.js.map +0 -1
  29. package/dist/index.d.ts +0 -3
  30. package/dist/index.d.ts.map +0 -1
  31. package/dist/index.js +0 -33
  32. package/dist/index.js.map +0 -1
  33. package/dist/schemas/index.d.ts +0 -308
  34. package/dist/schemas/index.d.ts.map +0 -1
  35. package/dist/schemas/index.js +0 -191
  36. package/dist/schemas/index.js.map +0 -1
  37. package/dist/services/formatters.d.ts +0 -22
  38. package/dist/services/formatters.d.ts.map +0 -1
  39. package/dist/services/formatters.js +0 -52
  40. package/dist/services/formatters.js.map +0 -1
  41. package/dist/services/wp-client.d.ts +0 -38
  42. package/dist/services/wp-client.d.ts.map +0 -1
  43. package/dist/services/wp-client.js +0 -102
  44. package/dist/services/wp-client.js.map +0 -1
  45. package/dist/tools/content.d.ts +0 -4
  46. package/dist/tools/content.d.ts.map +0 -1
  47. package/dist/tools/content.js +0 -196
  48. package/dist/tools/content.js.map +0 -1
  49. package/dist/tools/posts.d.ts +0 -4
  50. package/dist/tools/posts.d.ts.map +0 -1
  51. package/dist/tools/posts.js +0 -179
  52. package/dist/tools/posts.js.map +0 -1
  53. package/dist/tools/seo.d.ts +0 -4
  54. package/dist/tools/seo.d.ts.map +0 -1
  55. package/dist/tools/seo.js +0 -241
  56. package/dist/tools/seo.js.map +0 -1
  57. package/dist/tools/taxonomy.d.ts +0 -4
  58. package/dist/tools/taxonomy.d.ts.map +0 -1
  59. package/dist/tools/taxonomy.js +0 -82
  60. package/dist/tools/taxonomy.js.map +0 -1
  61. package/dist/types.d.ts +0 -160
  62. package/dist/types.d.ts.map +0 -1
  63. package/dist/types.js +0 -3
  64. package/dist/types.js.map +0 -1
package/index.js ADDED
@@ -0,0 +1,1367 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * WordPress MCP Server v2.2.0 — Enterprise Edition
5
+ *
6
+ * SAFETY MODEL:
7
+ * This MCP server is designed for safe operation: default non-destructive
8
+ * behavior, configurable restrictions (read-only, draft-only, disable-delete),
9
+ * structured audit logging, and multi-target site management. All destructive
10
+ * actions require explicit opt-in and can be disabled globally.
11
+ *
12
+ * Built by AdSim SRL — https://adsim.be
13
+ */
14
+
15
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
+ import {
18
+ CallToolRequestSchema,
19
+ ListToolsRequestSchema,
20
+ } from '@modelcontextprotocol/sdk/types.js';
21
+ import fetch from 'node-fetch';
22
+ import { readFileSync, existsSync } from 'fs';
23
+ import { resolve } from 'path';
24
+ import { HttpTransportManager } from './src/transport/http.js';
25
+
26
+ // ============================================================
27
+ // CONFIGURATION
28
+ // ============================================================
29
+
30
+ const VERSION = '2.2.0';
31
+ const VERBOSE = process.env.WP_MCP_VERBOSE === 'true' || process.argv.includes('--verbose');
32
+ const MAX_RETRIES = parseInt(process.env.WP_MCP_MAX_RETRIES || '3', 10);
33
+ const TIMEOUT_MS = parseInt(process.env.WP_MCP_TIMEOUT || '30000', 10);
34
+ const RETRY_BASE_DELAY_MS = 1000;
35
+
36
+ // ============================================================
37
+ // ENTERPRISE CONTROLS
38
+ // ============================================================
39
+
40
+ const READ_ONLY = process.env.WP_READ_ONLY === 'true';
41
+ const DRAFT_ONLY = process.env.WP_DRAFT_ONLY === 'true';
42
+ const DISABLE_DELETE = process.env.WP_DISABLE_DELETE === 'true';
43
+ const DISABLE_PLUGIN_MANAGEMENT = process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true';
44
+ const MAX_CALLS_PER_MINUTE = parseInt(process.env.WP_MAX_CALLS_PER_MINUTE || '0', 10); // 0 = unlimited
45
+ const ALLOWED_TYPES = process.env.WP_ALLOWED_TYPES ? process.env.WP_ALLOWED_TYPES.split(',').map(s => s.trim()) : null; // null = all
46
+ const ALLOWED_STATUSES = process.env.WP_ALLOWED_STATUSES ? process.env.WP_ALLOWED_STATUSES.split(',').map(s => s.trim()) : null;
47
+ const AUDIT_LOG = process.env.WP_AUDIT_LOG !== 'off'; // on by default
48
+
49
+ // Rate limiter state
50
+ const rateLimiter = { calls: [], windowMs: 60000 };
51
+
52
+ function checkRateLimit() {
53
+ if (MAX_CALLS_PER_MINUTE <= 0) return;
54
+ const now = Date.now();
55
+ rateLimiter.calls = rateLimiter.calls.filter(t => now - t < rateLimiter.windowMs);
56
+ if (rateLimiter.calls.length >= MAX_CALLS_PER_MINUTE) {
57
+ throw new Error(`Rate limit exceeded: ${MAX_CALLS_PER_MINUTE} calls/minute. Try again in a few seconds.`);
58
+ }
59
+ rateLimiter.calls.push(now);
60
+ }
61
+
62
+ function enforceReadOnly(toolName) {
63
+ 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'];
64
+ if (process.env.WP_READ_ONLY === 'true' && writeTools.includes(toolName)) {
65
+ throw new Error(`Blocked: Server is in READ-ONLY mode (WP_READ_ONLY=true). Tool "${toolName}" is not allowed.`);
66
+ }
67
+ }
68
+
69
+ function enforceDeleteDisabled(toolName) {
70
+ const deleteTools = ['wp_delete_post', 'wp_delete_revision'];
71
+ if (process.env.WP_DISABLE_DELETE === 'true' && deleteTools.includes(toolName)) {
72
+ throw new Error(`Blocked: Destructive actions are disabled (WP_DISABLE_DELETE=true). Tool "${toolName}" is not allowed.`);
73
+ }
74
+ }
75
+
76
+ function enforcePluginManagement(toolName) {
77
+ const pluginWriteTools = ['wp_activate_plugin', 'wp_deactivate_plugin'];
78
+ if (process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true' && pluginWriteTools.includes(toolName)) {
79
+ throw new Error(`Blocked: Plugin management is disabled (WP_DISABLE_PLUGIN_MANAGEMENT=true). Tool "${toolName}" is not allowed.`);
80
+ }
81
+ }
82
+
83
+ function enforceDraftOnly(status) {
84
+ if (process.env.WP_DRAFT_ONLY === 'true' && status && status !== 'draft' && status !== 'pending') {
85
+ throw new Error(`Blocked: Server is in DRAFT-ONLY mode (WP_DRAFT_ONLY=true). Only "draft" and "pending" statuses are allowed, got "${status}".`);
86
+ }
87
+ }
88
+
89
+ function enforceAllowedTypes(postType) {
90
+ if (ALLOWED_TYPES && postType && !ALLOWED_TYPES.includes(postType)) {
91
+ throw new Error(`Blocked: Post type "${postType}" is not in the allowed list (WP_ALLOWED_TYPES=${ALLOWED_TYPES.join(',')}). Allowed: ${ALLOWED_TYPES.join(', ')}.`);
92
+ }
93
+ }
94
+
95
+ function enforceAllowedStatuses(status) {
96
+ if (ALLOWED_STATUSES && status && !ALLOWED_STATUSES.includes(status)) {
97
+ throw new Error(`Blocked: Status "${status}" is not allowed (WP_ALLOWED_STATUSES=${ALLOWED_STATUSES.join(',')}). Allowed: ${ALLOWED_STATUSES.join(', ')}.`);
98
+ }
99
+ }
100
+
101
+ // ============================================================
102
+ // LOGGER & AUDIT
103
+ // ============================================================
104
+
105
+ const log = {
106
+ info: (msg, data) => console.error(`[INFO] ${msg}${data ? ' ' + JSON.stringify(data) : ''}`),
107
+ debug: (msg, data) => { if (VERBOSE) console.error(`[DEBUG] ${msg}${data ? ' ' + JSON.stringify(data) : ''}`); },
108
+ warn: (msg, data) => console.error(`[WARN] ${msg}${data ? ' ' + JSON.stringify(data) : ''}`),
109
+ error: (msg, data) => console.error(`[ERROR] ${msg}${data ? ' ' + JSON.stringify(data) : ''}`)
110
+ };
111
+
112
+ function auditLog(entry) {
113
+ if (!AUDIT_LOG) return;
114
+ const record = {
115
+ timestamp: new Date().toISOString(),
116
+ tool: entry.tool,
117
+ target: entry.target || null,
118
+ target_type: entry.target_type || null,
119
+ action: entry.action || null,
120
+ status: entry.status, // success | error | blocked
121
+ latency_ms: entry.latency_ms,
122
+ site: entry.site || currentTarget?.name || 'default',
123
+ params: entry.params || {},
124
+ error: entry.error || null
125
+ };
126
+ console.error(`[AUDIT] ${JSON.stringify(record)}`);
127
+ }
128
+
129
+ // Sanitize params for audit (remove content/body to keep logs small)
130
+ function sanitizeParams(args) {
131
+ const safe = { ...args };
132
+ if (safe.content && safe.content.length > 100) safe.content = `[${safe.content.length} chars]`;
133
+ if (safe.body) safe.body = '[binary]';
134
+ return safe;
135
+ }
136
+
137
+ // ============================================================
138
+ // MULTI-TARGET SUPPORT
139
+ // ============================================================
140
+
141
+ let targets = {};
142
+ let currentTarget = null;
143
+
144
+ function loadTargets() {
145
+ // Method 1: WP_TARGETS_JSON env var
146
+ if (process.env.WP_TARGETS_JSON) {
147
+ try {
148
+ targets = JSON.parse(process.env.WP_TARGETS_JSON);
149
+ const keys = Object.keys(targets);
150
+ if (keys.length > 0) {
151
+ currentTarget = { name: keys[0], ...targets[keys[0]] };
152
+ log.info(`Multi-target: ${keys.length} sites loaded. Active: ${keys[0]}`);
153
+ return true;
154
+ }
155
+ } catch (e) {
156
+ log.warn(`Invalid WP_TARGETS_JSON: ${e.message}`);
157
+ }
158
+ }
159
+
160
+ // Method 2: WP_TARGETS_FILE path to JSON file
161
+ if (process.env.WP_TARGETS_FILE && existsSync(process.env.WP_TARGETS_FILE)) {
162
+ try {
163
+ const content = readFileSync(process.env.WP_TARGETS_FILE, 'utf-8');
164
+ targets = JSON.parse(content);
165
+ const keys = Object.keys(targets);
166
+ if (keys.length > 0) {
167
+ currentTarget = { name: keys[0], ...targets[keys[0]] };
168
+ log.info(`Multi-target from file: ${keys.length} sites. Active: ${keys[0]}`);
169
+ return true;
170
+ }
171
+ } catch (e) {
172
+ log.warn(`Failed to load targets file: ${e.message}`);
173
+ }
174
+ }
175
+
176
+ return false;
177
+ }
178
+
179
+ function getActiveAuth() {
180
+ if (currentTarget?.url) {
181
+ return {
182
+ url: currentTarget.url.replace(/\/+$/, ''),
183
+ auth: Buffer.from(`${currentTarget.username}:${currentTarget.password}`).toString('base64')
184
+ };
185
+ }
186
+ return { url: WP_API_URL, auth: defaultAuth };
187
+ }
188
+
189
+ // ============================================================
190
+ // DOTENV SUPPORT
191
+ // ============================================================
192
+
193
+ function loadEnvFile() {
194
+ const envPaths = [resolve(process.cwd(), '.env'), resolve(new URL('.', import.meta.url).pathname, '.env')];
195
+ for (const envPath of envPaths) {
196
+ if (existsSync(envPath)) {
197
+ try {
198
+ const content = readFileSync(envPath, 'utf-8');
199
+ for (const line of content.split('\n')) {
200
+ const trimmed = line.trim();
201
+ if (!trimmed || trimmed.startsWith('#')) continue;
202
+ const match = trimmed.match(/^([^=]+)=\s*(.*)$/);
203
+ if (match) {
204
+ const key = match[1].trim();
205
+ let value = match[2].trim();
206
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
207
+ if (!process.env[key]) process.env[key] = value;
208
+ }
209
+ }
210
+ log.info(`Loaded .env from ${envPath}`);
211
+ return;
212
+ } catch (err) { log.warn(`.env error: ${err.message}`); }
213
+ }
214
+ }
215
+ }
216
+
217
+ loadEnvFile();
218
+
219
+ // ============================================================
220
+ // WORDPRESS CONFIGURATION
221
+ // ============================================================
222
+
223
+ const WP_API_URL = process.env.WP_API_URL?.replace(/\/+$/, '') || '';
224
+ const WP_API_USERNAME = process.env.WP_API_USERNAME || '';
225
+ const WP_API_PASSWORD = process.env.WP_API_PASSWORD || '';
226
+
227
+ // Load multi-target (may override default)
228
+ const isMultiTarget = loadTargets();
229
+
230
+ // Validate default config if not multi-target
231
+ if (!isMultiTarget) {
232
+ const missing = [];
233
+ if (!WP_API_URL) missing.push('WP_API_URL');
234
+ if (!WP_API_USERNAME) missing.push('WP_API_USERNAME');
235
+ if (!WP_API_PASSWORD) missing.push('WP_API_PASSWORD');
236
+ if (missing.length > 0) { log.error(`Missing: ${missing.join(', ')}`); if (process.env.NODE_ENV !== 'test') process.exit(1); }
237
+
238
+ try {
239
+ const url = new URL(WP_API_URL);
240
+ if (url.protocol !== 'https:' && !WP_API_URL.includes('localhost') && !WP_API_URL.includes('127.0.0.1'))
241
+ log.warn('WP_API_URL not HTTPS. Insecure for production.');
242
+ } catch { log.error(`Invalid WP_API_URL: "${WP_API_URL}".`); process.exit(1); }
243
+ }
244
+
245
+ const defaultAuth = WP_API_USERNAME && WP_API_PASSWORD
246
+ ? Buffer.from(`${WP_API_USERNAME}:${WP_API_PASSWORD}`).toString('base64')
247
+ : '';
248
+
249
+ // ============================================================
250
+ // INPUT VALIDATION
251
+ // ============================================================
252
+
253
+ function validateInput(args, rules) {
254
+ const errors = [];
255
+ for (const [field, rule] of Object.entries(rules)) {
256
+ const value = args[field];
257
+ if (rule.required && (value === undefined || value === null || value === '')) { errors.push(`"${field}" is required`); continue; }
258
+ if (value === undefined || value === null) continue;
259
+ if (rule.type === 'number') {
260
+ if (typeof value !== 'number' || isNaN(value)) errors.push(`"${field}" must be a number`);
261
+ 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}`); }
262
+ }
263
+ if (rule.type === 'string' && typeof value !== 'string') errors.push(`"${field}" must be a string`);
264
+ else if (rule.type === 'string' && rule.enum && !rule.enum.includes(value)) errors.push(`"${field}" must be: ${rule.enum.join(', ')}`);
265
+ if (rule.type === 'array' && !Array.isArray(value)) errors.push(`"${field}" must be an array`);
266
+ }
267
+ if (errors.length > 0) throw new Error(`Validation:\n${errors.map(e => ` - ${e}`).join('\n')}`);
268
+ }
269
+
270
+ // ============================================================
271
+ // API CALLER WITH RETRY, TIMEOUT & AUDIT
272
+ // ============================================================
273
+
274
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
275
+
276
+ async function wpApiCall(endpoint, options = {}) {
277
+ const { url: baseUrl, auth: activeAuth } = getActiveAuth();
278
+ const basePath = options.basePath || '/wp-json/wp/v2';
279
+ const url = `${baseUrl}${basePath}${endpoint}`;
280
+ const method = options.method || 'GET';
281
+ const headers = {
282
+ 'Authorization': `Basic ${activeAuth}`,
283
+ 'User-Agent': `WordPress-MCP-Server/${VERSION}`,
284
+ ...options.headers
285
+ };
286
+ if (!options.isMultipart) headers['Content-Type'] = 'application/json';
287
+
288
+ let lastError;
289
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
290
+ const controller = new AbortController();
291
+ const tid = setTimeout(() => controller.abort(), TIMEOUT_MS);
292
+ try {
293
+ log.debug(`${method} ${endpoint} (${attempt}/${MAX_RETRIES})`);
294
+ const t0 = Date.now();
295
+ const fetchOpts = { method, headers, signal: controller.signal };
296
+ if (options.body) fetchOpts.body = options.body;
297
+ const response = await fetch(url, fetchOpts);
298
+ clearTimeout(tid);
299
+ log.debug(`${response.status} in ${Date.now() - t0}ms`);
300
+
301
+ if (!response.ok) {
302
+ const errorText = await response.text();
303
+ const sc = response.status;
304
+ if (sc >= 400 && sc < 500 && sc !== 429) {
305
+ const hints = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed' };
306
+ throw new Error(`WP API ${sc}: ${hints[sc] || ''}\n${errorText}`);
307
+ }
308
+ lastError = new Error(`WP API ${sc}: ${errorText}`);
309
+ if (sc === 429) { await sleep(parseInt(response.headers.get('retry-after') || '5', 10) * 1000); continue; }
310
+ if (attempt < MAX_RETRIES) { await sleep(RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1)); continue; }
311
+ throw lastError;
312
+ }
313
+ const ct = response.headers.get('content-type');
314
+ if (!ct || !ct.includes('application/json')) return { success: true, status: response.status };
315
+ return await response.json();
316
+ } catch (error) {
317
+ clearTimeout(tid);
318
+ if (error.name === 'AbortError') lastError = new Error(`Timeout ${TIMEOUT_MS}ms`);
319
+ else if (error.message.includes('WP API 4')) throw error;
320
+ else lastError = error;
321
+ if (attempt < MAX_RETRIES) await sleep(RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1));
322
+ }
323
+ }
324
+ throw lastError || new Error(`Failed after ${MAX_RETRIES} attempts`);
325
+ }
326
+
327
+ // ============================================================
328
+ // HEALTH CHECK
329
+ // ============================================================
330
+
331
+ async function healthCheck() {
332
+ log.info(`WordPress MCP Server v${VERSION} starting...`);
333
+ const { url: baseUrl, auth: activeAuth } = getActiveAuth();
334
+ log.info(`Target: ${baseUrl}`);
335
+
336
+ // Log enterprise controls
337
+ if (READ_ONLY) log.info('Mode: READ-ONLY (write operations blocked)');
338
+ if (DRAFT_ONLY) log.info('Mode: DRAFT-ONLY (only draft/pending statuses allowed)');
339
+ if (DISABLE_DELETE) log.info('Mode: DELETE DISABLED');
340
+ if (DISABLE_PLUGIN_MANAGEMENT) log.info('Mode: PLUGIN MANAGEMENT DISABLED');
341
+ if (ALLOWED_TYPES) log.info(`Allowed types: ${ALLOWED_TYPES.join(', ')}`);
342
+ if (ALLOWED_STATUSES) log.info(`Allowed statuses: ${ALLOWED_STATUSES.join(', ')}`);
343
+ if (MAX_CALLS_PER_MINUTE > 0) log.info(`Rate limit: ${MAX_CALLS_PER_MINUTE} calls/min`);
344
+ if (isMultiTarget) log.info(`Multi-target: ${Object.keys(targets).length} sites configured`);
345
+ log.info(`Audit log: ${AUDIT_LOG ? 'ON' : 'OFF'}`);
346
+
347
+ try {
348
+ const ctrl = new AbortController();
349
+ const tid = setTimeout(() => ctrl.abort(), 10000);
350
+ const resp = await fetch(`${baseUrl}/wp-json`, { headers: { 'User-Agent': `WordPress-MCP-Server/${VERSION}` }, signal: ctrl.signal });
351
+ clearTimeout(tid);
352
+ if (!resp.ok) { log.warn(`REST API: ${resp.status}`); return; }
353
+ const info = await resp.json();
354
+ log.info(`Connected: ${info.name || 'WordPress'}`);
355
+ const authResp = await fetch(`${baseUrl}/wp-json/wp/v2/users/me`, { headers: { 'Authorization': `Basic ${activeAuth}`, 'User-Agent': `WordPress-MCP-Server/${VERSION}` } });
356
+ if (authResp.ok) { const u = await authResp.json(); log.info(`Auth: ${u.name} — ${u.roles?.[0]}`); }
357
+ else log.warn(`Auth failed: ${authResp.status}`);
358
+ } catch (e) { log.warn(`Health check: ${e.message}`); }
359
+ }
360
+
361
+ // ============================================================
362
+ // MCP SERVER
363
+ // ============================================================
364
+
365
+ const server = new Server(
366
+ { name: 'wordpress-mcp', version: VERSION },
367
+ { capabilities: { tools: {} } }
368
+ );
369
+
370
+ // ============================================================
371
+ // CONSTANTS & HELPERS
372
+ // ============================================================
373
+
374
+ const STATUSES = ['publish', 'draft', 'pending', 'private', 'future', 'trash'];
375
+ const ORDERBY = ['date', 'relevance', 'id', 'title', 'slug', 'modified', 'author'];
376
+ const ORDERS = ['asc', 'desc'];
377
+ const MEDIA_TYPES = ['image', 'video', 'audio', 'application'];
378
+ const COMMENT_STATUSES = ['approved', 'hold', 'spam', 'trash'];
379
+ const TOOLS_COUNT = 35;
380
+
381
+ function json(data) { return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; }
382
+ function strip(html) { return (html || '').replace(/<[^>]*>/g, '').trim(); }
383
+
384
+ // ============================================================
385
+ // TOOL DEFINITIONS (35 tools)
386
+ // ============================================================
387
+
388
+ const TOOLS_DEFINITIONS = [
389
+ // ── POSTS (6) ──
390
+ { name: 'wp_list_posts', description: 'List posts with filtering and search.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'publish' }, orderby: { type: 'string', default: 'date' }, order: { type: 'string', default: 'desc' }, categories: { type: 'string' }, tags: { type: 'string' }, search: { type: 'string' }, author: { type: 'number' } }}},
391
+ { name: 'wp_get_post', description: 'Get post by ID with full content and meta.', inputSchema: { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] }},
392
+ { name: 'wp_create_post', description: 'Create a post (default: draft).', inputSchema: { type: 'object', properties: { title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', default: 'draft' }, excerpt: { type: 'string' }, categories: { type: 'array', items: { type: 'number' } }, tags: { type: 'array', items: { type: 'number' } }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' }, author: { type: 'number' } }, required: ['title', 'content'] }},
393
+ { name: 'wp_update_post', description: 'Update a post.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string' }, excerpt: { type: 'string' }, categories: { type: 'array', items: { type: 'number' } }, tags: { type: 'array', items: { type: 'number' } }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' }, author: { type: 'number' } }, required: ['id'] }},
394
+ { name: 'wp_delete_post', description: 'Delete a post (trash or permanent).', inputSchema: { type: 'object', properties: { id: { type: 'number' }, force: { type: 'boolean', default: false } }, required: ['id'] }},
395
+ { name: 'wp_search', description: 'Full-text search across all content.', inputSchema: { type: 'object', properties: { search: { type: 'string' }, per_page: { type: 'number', default: 10 }, type: { type: 'string', default: '' } }, required: ['search'] }},
396
+
397
+ // ── PAGES (4) ──
398
+ { name: 'wp_list_pages', description: 'List pages with hierarchy.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'publish' }, parent: { type: 'number' }, orderby: { type: 'string', default: 'menu_order' }, order: { type: 'string', default: 'asc' }, search: { type: 'string' } }}},
399
+ { name: 'wp_get_page', description: 'Get page by ID with content and template.', inputSchema: { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] }},
400
+ { name: 'wp_create_page', description: 'Create a page (default: draft).', inputSchema: { type: 'object', properties: { title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', default: 'draft' }, parent: { type: 'number', default: 0 }, template: { type: 'string' }, menu_order: { type: 'number', default: 0 }, excerpt: { type: 'string' }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' } }, required: ['title', 'content'] }},
401
+ { name: 'wp_update_page', description: 'Update a page.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string' }, parent: { type: 'number' }, template: { type: 'string' }, menu_order: { type: 'number' }, excerpt: { type: 'string' }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' } }, required: ['id'] }},
402
+
403
+ // ── MEDIA (3) ──
404
+ { name: 'wp_list_media', description: 'List media files.', 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' } }}},
405
+ { name: 'wp_get_media', description: 'Get media details with all sizes.', inputSchema: { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] }},
406
+ { name: 'wp_upload_media', description: 'Upload media from URL.', 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'] }},
407
+
408
+ // ── TAXONOMIES (3) ──
409
+ { name: 'wp_list_categories', description: 'List categories with hierarchy and post count.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 100 }, page: { type: 'number', default: 1 }, parent: { type: 'number' }, search: { type: 'string' }, orderby: { type: 'string', default: 'name' }, hide_empty: { type: 'boolean', default: false } }}},
410
+ { name: 'wp_list_tags', description: 'List tags with post count.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 100 }, page: { type: 'number', default: 1 }, search: { type: 'string' }, orderby: { type: 'string', default: 'name' }, hide_empty: { type: 'boolean', default: false } }}},
411
+ { name: 'wp_create_taxonomy_term', description: 'Create a category or tag.', inputSchema: { type: 'object', properties: { taxonomy: { type: 'string' }, name: { type: 'string' }, slug: { type: 'string' }, description: { type: 'string' }, parent: { type: 'number' } }, required: ['taxonomy', 'name'] }},
412
+
413
+ // ── COMMENTS (2) ──
414
+ { name: 'wp_list_comments', description: 'List comments with filters.', 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' } }}},
415
+ { name: 'wp_create_comment', description: 'Create a comment or reply.', 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'] }},
416
+
417
+ // ── CUSTOM POST TYPES (2) ──
418
+ { name: 'wp_list_post_types', description: 'Discover all registered post types (CPT).', inputSchema: { type: 'object', properties: {} }},
419
+ { name: 'wp_list_custom_posts', description: 'List posts from any custom post type.', inputSchema: { type: 'object', properties: { post_type: { type: 'string' }, per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'publish' }, orderby: { type: 'string', default: 'date' }, order: { type: 'string', default: 'desc' }, search: { type: 'string' } }, required: ['post_type'] }},
420
+
421
+ // ── USERS (1) ──
422
+ { name: 'wp_list_users', description: 'List users with roles.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, roles: { type: 'string' }, search: { type: 'string' }, orderby: { type: 'string', default: 'name' } }}},
423
+
424
+ // ── MULTI-TARGET (1) ──
425
+ { name: 'wp_set_target', description: 'Switch active WordPress site (multi-target mode). Use wp_list_targets to see available sites.',
426
+ inputSchema: { type: 'object', properties: { site: { type: 'string', description: 'Site key from targets config' } }, required: ['site'] }},
427
+
428
+ // ── SITE INFO (1) ──
429
+ { name: 'wp_site_info', description: 'Get site info, current user, post types, enterprise controls, and available targets.',
430
+ inputSchema: { type: 'object', properties: {} }},
431
+
432
+ // ── SEO METADATA (3) ──
433
+ { name: 'wp_get_seo_meta', description: 'Get SEO metadata (title, description, focus keyword, robots, canonical, og) for a post or page. Auto-detects Yoast SEO, RankMath, SEOPress, or All in One SEO.',
434
+ inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Post or page ID' }, post_type: { type: 'string', default: 'post', description: 'post or page' } }, required: ['id'] }},
435
+ { name: 'wp_update_seo_meta', description: 'Update SEO metadata (title, description, focus keyword) for a post or page. Auto-detects installed SEO plugin.',
436
+ inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'Post or page ID' }, post_type: { type: 'string', default: 'post', description: 'post or page' }, title: { type: 'string', description: 'SEO title' }, description: { type: 'string', description: 'Meta description' }, focus_keyword: { type: 'string', description: 'Focus keyword' }, canonical_url: { type: 'string', description: 'Canonical URL' }, robots_noindex: { type: 'boolean', description: 'Set noindex' }, robots_nofollow: { type: 'boolean', description: 'Set nofollow' } }, required: ['id'] }},
437
+ { name: 'wp_audit_seo', description: 'Audit SEO metadata across multiple posts/pages. Returns missing titles, descriptions, keywords, and quality scores.',
438
+ inputSchema: { type: 'object', properties: { post_type: { type: 'string', default: 'post', description: 'post or page' }, per_page: { type: 'number', default: 20, description: 'Number of posts to audit (max 100)' }, status: { type: 'string', default: 'publish' }, orderby: { type: 'string', default: 'date' }, order: { type: 'string', default: 'desc' } }}},
439
+
440
+ // ── PLUGINS (3) ──
441
+ { name: 'wp_list_plugins', description: 'List WordPress plugins with status filtering. Requires Administrator role (activate_plugins capability). Returns plugin name, version, status, author, and description.',
442
+ inputSchema: { type: 'object', properties: { search: { type: 'string', description: 'Filter plugins by search term' }, status: { type: 'string', enum: ['active', 'inactive', 'all'], default: 'all', description: 'Filter by plugin status (active, inactive, all)' }, per_page: { type: 'number', default: 20, description: 'Number of plugins to return (1-100)' } }}},
443
+ { name: 'wp_activate_plugin', description: 'Activate a WordPress plugin. Requires Administrator role (activate_plugins capability). Blocked by WP_READ_ONLY and WP_DISABLE_PLUGIN_MANAGEMENT.',
444
+ inputSchema: { type: 'object', properties: { plugin: { type: 'string', description: 'Plugin slug/file (e.g. "akismet/akismet.php"). Use wp_list_plugins to find the correct value.' } }, required: ['plugin'] }},
445
+ { name: 'wp_deactivate_plugin', description: 'Deactivate a WordPress plugin. Requires Administrator role (activate_plugins capability). Blocked by WP_READ_ONLY and WP_DISABLE_PLUGIN_MANAGEMENT.',
446
+ inputSchema: { type: 'object', properties: { plugin: { type: 'string', description: 'Plugin slug/file (e.g. "akismet/akismet.php"). Use wp_list_plugins to find the correct value.' } }, required: ['plugin'] }},
447
+
448
+ // ── THEMES (2) ──
449
+ { name: 'wp_list_themes', description: 'List installed WordPress themes with status filtering. Requires switch_themes capability (Administrator or Editor role).',
450
+ inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['active', 'inactive', 'all'], default: 'all', description: 'Filter by theme status' }, per_page: { type: 'number', default: 20, description: 'Number of themes to return (1-100)' } }}},
451
+ { name: 'wp_get_theme', description: 'Get details of a specific WordPress theme by stylesheet slug. Requires switch_themes capability.',
452
+ inputSchema: { type: 'object', properties: { stylesheet: { type: 'string', description: 'Theme stylesheet slug (e.g. "twentytwentyfour"). Use wp_list_themes to find the correct value.' } }, required: ['stylesheet'] }},
453
+
454
+ // ── REVISIONS (4) ──
455
+ { name: 'wp_list_revisions', description: 'List revisions of a post or page. Returns metadata without content (use wp_get_revision for full content).',
456
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID' }, post_type: { type: 'string', enum: ['post', 'page'], default: 'post', description: 'Post type' }, per_page: { type: 'number', default: 10, description: 'Number of revisions to return (1-100)' } }, required: ['post_id'] }},
457
+ { name: 'wp_get_revision', description: 'Get a specific revision with full content.',
458
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID' }, revision_id: { type: 'number', description: 'Revision ID' }, post_type: { type: 'string', enum: ['post', 'page'], default: 'post', description: 'Post type' } }, required: ['post_id', 'revision_id'] }},
459
+ { name: 'wp_restore_revision', description: 'Restore a post or page to a previous revision. Copies revision content back to the post. Blocked by WP_READ_ONLY.',
460
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID' }, revision_id: { type: 'number', description: 'Revision ID to restore' }, post_type: { type: 'string', enum: ['post', 'page'], default: 'post', description: 'Post type' } }, required: ['post_id', 'revision_id'] }},
461
+ { name: 'wp_delete_revision', description: 'Permanently delete a revision. This action cannot be undone. Blocked by WP_READ_ONLY and WP_DISABLE_DELETE.',
462
+ inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'Post or page ID' }, revision_id: { type: 'number', description: 'Revision ID to delete' }, post_type: { type: 'string', enum: ['post', 'page'], default: 'post', description: 'Post type' } }, required: ['post_id', 'revision_id'] }}
463
+ ];
464
+
465
+ function registerHandlers(s) {
466
+ s.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS_DEFINITIONS }));
467
+ s.setRequestHandler(CallToolRequestSchema, handleToolCall);
468
+ }
469
+
470
+ registerHandlers(server);
471
+
472
+ // ============================================================
473
+ // TOOL EXECUTION (27 tools)
474
+ // ============================================================
475
+
476
+ export async function handleToolCall(request) {
477
+ const { name, arguments: args = {} } = request.params;
478
+ const t0 = Date.now();
479
+
480
+ // Enterprise controls: rate limit & read-only check
481
+ try {
482
+ checkRateLimit();
483
+ enforceReadOnly(name);
484
+ enforceDeleteDisabled(name);
485
+ enforcePluginManagement(name);
486
+ } catch (error) {
487
+ auditLog({ tool: name, status: 'blocked', latency_ms: Date.now() - t0, params: sanitizeParams(args), error: error.message });
488
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
489
+ }
490
+
491
+ log.debug(`Tool: ${name}`, args);
492
+
493
+ try {
494
+ let result;
495
+
496
+ switch (name) {
497
+
498
+ // ── POSTS ──
499
+
500
+ case 'wp_list_posts': {
501
+ validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 }, status: { type: 'string', enum: STATUSES }, orderby: { type: 'string', enum: ORDERBY }, order: { type: 'string', enum: ORDERS } });
502
+ const { per_page = 10, page = 1, status = 'publish', orderby = 'date', order = 'desc', categories, tags, search, author } = args;
503
+ let ep = `/posts?per_page=${per_page}&page=${page}&status=${status}&orderby=${orderby}&order=${order}`;
504
+ if (categories) ep += `&categories=${categories}`; if (tags) ep += `&tags=${tags}`;
505
+ if (search) ep += `&search=${encodeURIComponent(search)}`; if (author) ep += `&author=${author}`;
506
+ const posts = await wpApiCall(ep);
507
+ result = json({ total: posts.length, page, posts: posts.map(p => ({ id: p.id, title: p.title.rendered, status: p.status, date: p.date, modified: p.modified, link: p.link, author: p.author, categories: p.categories, tags: p.tags, excerpt: strip(p.excerpt.rendered).substring(0, 200) })) });
508
+ auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
509
+ break;
510
+ }
511
+
512
+ case 'wp_get_post': {
513
+ validateInput(args, { id: { type: 'number', required: true, min: 1 } });
514
+ const p = await wpApiCall(`/posts/${args.id}`);
515
+ result = json({ id: p.id, title: p.title.rendered, content: p.content.rendered, excerpt: p.excerpt.rendered, status: p.status, date: p.date, modified: p.modified, link: p.link, slug: p.slug, categories: p.categories, tags: p.tags, author: p.author, featured_media: p.featured_media, comment_status: p.comment_status, meta: p.meta || {} });
516
+ auditLog({ tool: name, target: args.id, target_type: 'post', action: 'read', status: 'success', latency_ms: Date.now() - t0 });
517
+ break;
518
+ }
519
+
520
+ case 'wp_create_post': {
521
+ validateInput(args, { title: { type: 'string', required: true }, content: { type: 'string', required: true }, status: { type: 'string', enum: STATUSES.filter(s => s !== 'trash') } });
522
+ enforceDraftOnly(args.status); enforceAllowedStatuses(args.status); enforceAllowedTypes('post');
523
+ const { title, content, status = 'draft', excerpt, categories, tags, slug, featured_media, meta, author } = args;
524
+ const data = { title, content, status };
525
+ if (excerpt) data.excerpt = excerpt; if (categories) data.categories = categories; if (tags) data.tags = tags;
526
+ if (slug) data.slug = slug; if (featured_media) data.featured_media = featured_media; if (meta) data.meta = meta; if (author) data.author = author;
527
+ const np = await wpApiCall('/posts', { method: 'POST', body: JSON.stringify(data) });
528
+ result = json({ success: true, message: 'Post created', post: { id: np.id, title: np.title.rendered, status: np.status, link: np.link, slug: np.slug } });
529
+ auditLog({ tool: name, target: np.id, target_type: 'post', action: 'create', status: 'success', latency_ms: Date.now() - t0, params: { title, status } });
530
+ break;
531
+ }
532
+
533
+ case 'wp_update_post': {
534
+ validateInput(args, { id: { type: 'number', required: true, min: 1 }, status: { type: 'string', enum: STATUSES } });
535
+ if (args.status) { enforceDraftOnly(args.status); enforceAllowedStatuses(args.status); }
536
+ const { id, ...upd } = args;
537
+ const up = await wpApiCall(`/posts/${id}`, { method: 'POST', body: JSON.stringify(upd) });
538
+ result = json({ success: true, message: `Post ${id} updated`, post: { id: up.id, title: up.title.rendered, status: up.status, link: up.link, modified: up.modified } });
539
+ auditLog({ tool: name, target: id, target_type: 'post', action: 'update', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(upd) });
540
+ break;
541
+ }
542
+
543
+ case 'wp_delete_post': {
544
+ validateInput(args, { id: { type: 'number', required: true, min: 1 } });
545
+ const { id, force = false } = args;
546
+ const dp = await wpApiCall(`/posts/${id}${force ? '?force=true' : ''}`, { method: 'DELETE' });
547
+ result = json({ success: true, message: force ? `Post ${id} permanently deleted` : `Post ${id} trashed`, post: { id: dp.id, title: dp.title?.rendered || dp.previous?.title?.rendered, status: force ? 'deleted' : 'trash' } });
548
+ auditLog({ tool: name, target: id, target_type: 'post', action: force ? 'permanent_delete' : 'trash', status: 'success', latency_ms: Date.now() - t0 });
549
+ break;
550
+ }
551
+
552
+ case 'wp_search': {
553
+ validateInput(args, { search: { type: 'string', required: true }, per_page: { type: 'number', min: 1, max: 100 } });
554
+ const { search, per_page = 10, type } = args;
555
+ let ep = `/search?search=${encodeURIComponent(search)}&per_page=${per_page}`;
556
+ if (type) ep += `&type=${type}`;
557
+ const r = await wpApiCall(ep);
558
+ result = json({ query: search, total: r.length, results: r.map(x => ({ id: x.id, title: x.title, url: x.url, type: x.type, subtype: x.subtype })) });
559
+ auditLog({ tool: name, action: 'search', status: 'success', latency_ms: Date.now() - t0, params: { search, type } });
560
+ break;
561
+ }
562
+
563
+ // ── PAGES ──
564
+
565
+ case 'wp_list_pages': {
566
+ validateInput(args, { per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 }, status: { type: 'string', enum: STATUSES }, order: { type: 'string', enum: ORDERS } });
567
+ const { per_page = 10, page = 1, status = 'publish', parent, orderby = 'menu_order', order = 'asc', search } = args;
568
+ let ep = `/pages?per_page=${per_page}&page=${page}&status=${status}&orderby=${orderby}&order=${order}`;
569
+ if (parent !== undefined) ep += `&parent=${parent}`; if (search) ep += `&search=${encodeURIComponent(search)}`;
570
+ const pgs = await wpApiCall(ep);
571
+ result = json({ total: pgs.length, page, pages: pgs.map(p => ({ id: p.id, title: p.title.rendered, status: p.status, date: p.date, link: p.link, parent: p.parent, menu_order: p.menu_order, template: p.template, excerpt: strip(p.excerpt.rendered).substring(0, 200) })) });
572
+ auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
573
+ break;
574
+ }
575
+
576
+ case 'wp_get_page': {
577
+ validateInput(args, { id: { type: 'number', required: true, min: 1 } });
578
+ const pg = await wpApiCall(`/pages/${args.id}`);
579
+ result = json({ id: pg.id, title: pg.title.rendered, content: pg.content.rendered, excerpt: pg.excerpt.rendered, status: pg.status, date: pg.date, modified: pg.modified, link: pg.link, slug: pg.slug, parent: pg.parent, menu_order: pg.menu_order, template: pg.template, author: pg.author, featured_media: pg.featured_media, meta: pg.meta || {} });
580
+ auditLog({ tool: name, target: args.id, target_type: 'page', action: 'read', status: 'success', latency_ms: Date.now() - t0 });
581
+ break;
582
+ }
583
+
584
+ case 'wp_create_page': {
585
+ validateInput(args, { title: { type: 'string', required: true }, content: { type: 'string', required: true }, status: { type: 'string', enum: STATUSES.filter(s => s !== 'trash') } });
586
+ enforceDraftOnly(args.status); enforceAllowedStatuses(args.status); enforceAllowedTypes('page');
587
+ const { title, content, status = 'draft', parent = 0, template, menu_order = 0, excerpt, slug, featured_media, meta } = args;
588
+ const data = { title, content, status, parent, menu_order };
589
+ if (template) data.template = template; if (excerpt) data.excerpt = excerpt; if (slug) data.slug = slug;
590
+ if (featured_media) data.featured_media = featured_media; if (meta) data.meta = meta;
591
+ const np = await wpApiCall('/pages', { method: 'POST', body: JSON.stringify(data) });
592
+ result = json({ success: true, message: 'Page created', page: { id: np.id, title: np.title.rendered, status: np.status, link: np.link, parent: np.parent } });
593
+ auditLog({ tool: name, target: np.id, target_type: 'page', action: 'create', status: 'success', latency_ms: Date.now() - t0, params: { title, status } });
594
+ break;
595
+ }
596
+
597
+ case 'wp_update_page': {
598
+ validateInput(args, { id: { type: 'number', required: true, min: 1 }, status: { type: 'string', enum: STATUSES } });
599
+ if (args.status) { enforceDraftOnly(args.status); enforceAllowedStatuses(args.status); }
600
+ const { id, ...upd } = args;
601
+ const up = await wpApiCall(`/pages/${id}`, { method: 'POST', body: JSON.stringify(upd) });
602
+ result = json({ success: true, message: `Page ${id} updated`, page: { id: up.id, title: up.title.rendered, status: up.status, link: up.link, modified: up.modified } });
603
+ auditLog({ tool: name, target: id, target_type: 'page', action: 'update', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(upd) });
604
+ break;
605
+ }
606
+
607
+ // ── MEDIA ──
608
+
609
+ case 'wp_list_media': {
610
+ 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 } });
611
+ const { per_page = 10, page = 1, media_type, search, orderby = 'date', order = 'desc' } = args;
612
+ let ep = `/media?per_page=${per_page}&page=${page}&orderby=${orderby}&order=${order}`;
613
+ if (media_type) ep += `&media_type=${media_type}`; if (search) ep += `&search=${encodeURIComponent(search)}`;
614
+ const media = await wpApiCall(ep);
615
+ 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 })) });
616
+ auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
617
+ break;
618
+ }
619
+
620
+ case 'wp_get_media': {
621
+ validateInput(args, { id: { type: 'number', required: true, min: 1 } });
622
+ const m = await wpApiCall(`/media/${args.id}`);
623
+ const sizes = m.media_details?.sizes || {};
624
+ 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 }])) });
625
+ auditLog({ tool: name, target: args.id, target_type: 'media', action: 'read', status: 'success', latency_ms: Date.now() - t0 });
626
+ break;
627
+ }
628
+
629
+ case 'wp_upload_media': {
630
+ validateInput(args, { url: { type: 'string', required: true } });
631
+ const { url: fileUrl, filename, title, alt_text, caption, description, post_id } = args;
632
+ const dlResp = await fetch(fileUrl, { timeout: TIMEOUT_MS });
633
+ if (!dlResp.ok) throw new Error(`Download failed: ${dlResp.status}`);
634
+ const fileBuffer = await dlResp.buffer();
635
+ const contentType = dlResp.headers.get('content-type') || 'application/octet-stream';
636
+ const fname = filename || fileUrl.split('/').pop().split('?')[0] || 'upload';
637
+ let ep = '/media'; if (post_id) ep += `?post=${post_id}`;
638
+ const uploaded = await wpApiCall(ep, { method: 'POST', body: fileBuffer, isMultipart: true, headers: { 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${fname}"` } });
639
+ if (title || alt_text || caption || description) {
640
+ 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;
641
+ await wpApiCall(`/media/${uploaded.id}`, { method: 'POST', body: JSON.stringify(md) });
642
+ }
643
+ result = json({ success: true, message: `Uploaded: ${fname}`, media: { id: uploaded.id, source_url: uploaded.source_url, mime_type: uploaded.mime_type } });
644
+ auditLog({ tool: name, target: uploaded.id, target_type: 'media', action: 'upload', status: 'success', latency_ms: Date.now() - t0, params: { filename: fname } });
645
+ break;
646
+ }
647
+
648
+ // ── TAXONOMIES ──
649
+
650
+ case 'wp_list_categories': {
651
+ const { per_page = 100, page = 1, parent, search, orderby = 'name', hide_empty = false } = args;
652
+ let ep = `/categories?per_page=${per_page}&page=${page}&orderby=${orderby}&hide_empty=${hide_empty}`;
653
+ if (parent !== undefined) ep += `&parent=${parent}`; if (search) ep += `&search=${encodeURIComponent(search)}`;
654
+ const cats = await wpApiCall(ep);
655
+ result = json({ total: cats.length, categories: cats.map(c => ({ id: c.id, name: c.name, slug: c.slug, description: c.description, parent: c.parent, count: c.count })) });
656
+ auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
657
+ break;
658
+ }
659
+
660
+ case 'wp_list_tags': {
661
+ const { per_page = 100, page = 1, search, orderby = 'name', hide_empty = false } = args;
662
+ let ep = `/tags?per_page=${per_page}&page=${page}&orderby=${orderby}&hide_empty=${hide_empty}`;
663
+ if (search) ep += `&search=${encodeURIComponent(search)}`;
664
+ const tags = await wpApiCall(ep);
665
+ result = json({ total: tags.length, tags: tags.map(t => ({ id: t.id, name: t.name, slug: t.slug, count: t.count })) });
666
+ auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
667
+ break;
668
+ }
669
+
670
+ case 'wp_create_taxonomy_term': {
671
+ validateInput(args, { taxonomy: { type: 'string', required: true, enum: ['category', 'tag'] }, name: { type: 'string', required: true } });
672
+ const { taxonomy, name: tName, slug, description, parent } = args;
673
+ const ep = taxonomy === 'category' ? '/categories' : '/tags';
674
+ const data = { name: tName }; if (slug) data.slug = slug; if (description) data.description = description;
675
+ if (parent && taxonomy === 'category') data.parent = parent;
676
+ const t = await wpApiCall(ep, { method: 'POST', body: JSON.stringify(data) });
677
+ result = json({ success: true, message: `${taxonomy} "${tName}" created`, term: { id: t.id, name: t.name, slug: t.slug } });
678
+ auditLog({ tool: name, target: t.id, target_type: taxonomy, action: 'create', status: 'success', latency_ms: Date.now() - t0, params: { name: tName } });
679
+ break;
680
+ }
681
+
682
+ // ── COMMENTS ──
683
+
684
+ case 'wp_list_comments': {
685
+ const { per_page = 10, page = 1, post, status, orderby = 'date_gmt', order = 'desc', search } = args;
686
+ let ep = `/comments?per_page=${per_page}&page=${page}&orderby=${orderby}&order=${order}`;
687
+ if (post) ep += `&post=${post}`; if (status) ep += `&status=${status}`; if (search) ep += `&search=${encodeURIComponent(search)}`;
688
+ const comments = await wpApiCall(ep);
689
+ 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 })) });
690
+ auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
691
+ break;
692
+ }
693
+
694
+ case 'wp_create_comment': {
695
+ validateInput(args, { post: { type: 'number', required: true }, content: { type: 'string', required: true } });
696
+ const { post, content, parent = 0, author_name, author_email, status = 'approved' } = args;
697
+ const data = { post, content, parent, status };
698
+ if (author_name) data.author_name = author_name; if (author_email) data.author_email = author_email;
699
+ const c = await wpApiCall('/comments', { method: 'POST', body: JSON.stringify(data) });
700
+ 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) } });
701
+ auditLog({ tool: name, target: c.id, target_type: 'comment', action: 'create', status: 'success', latency_ms: Date.now() - t0, params: { post } });
702
+ break;
703
+ }
704
+
705
+ // ── CUSTOM POST TYPES ──
706
+
707
+ case 'wp_list_post_types': {
708
+ const types = await wpApiCall('/types');
709
+ result = json({ total: Object.keys(types).length, post_types: Object.values(types).map(t => ({ slug: t.slug, name: t.name, description: t.description, hierarchical: t.hierarchical, rest_base: t.rest_base })) });
710
+ auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
711
+ break;
712
+ }
713
+
714
+ case 'wp_list_custom_posts': {
715
+ validateInput(args, { post_type: { type: 'string', required: true }, per_page: { type: 'number', min: 1, max: 100 }, page: { type: 'number', min: 1 }, order: { type: 'string', enum: ORDERS } });
716
+ enforceAllowedTypes(args.post_type);
717
+ const { post_type, per_page = 10, page = 1, status = 'publish', orderby = 'date', order = 'desc', search } = args;
718
+ const types = await wpApiCall('/types');
719
+ const typeInfo = Object.values(types).find(t => t.slug === post_type || t.rest_base === post_type);
720
+ if (!typeInfo) throw new Error(`Post type "${post_type}" not found.`);
721
+ const restBase = typeInfo.rest_base || post_type;
722
+ let ep = `/${restBase}?per_page=${per_page}&page=${page}&status=${status}&orderby=${orderby}&order=${order}`;
723
+ if (search) ep += `&search=${encodeURIComponent(search)}`;
724
+ const posts = await wpApiCall(ep);
725
+ result = json({ post_type, total: posts.length, page, posts: posts.map(p => ({ id: p.id, title: p.title?.rendered || p.title, status: p.status, date: p.date, link: p.link, slug: p.slug, type: p.type, meta: p.meta || {} })) });
726
+ auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0, params: { post_type } });
727
+ break;
728
+ }
729
+
730
+ // ── USERS ──
731
+
732
+ case 'wp_list_users': {
733
+ const { per_page = 10, page = 1, roles, search, orderby = 'name' } = args;
734
+ let ep = `/users?per_page=${per_page}&page=${page}&orderby=${orderby}`;
735
+ if (roles) ep += `&roles=${roles}`; if (search) ep += `&search=${encodeURIComponent(search)}`;
736
+ const users = await wpApiCall(ep);
737
+ result = json({ total: users.length, users: users.map(u => ({ id: u.id, name: u.name, slug: u.slug, link: u.link, roles: u.roles, avatar: u.avatar_urls?.['96'] })) });
738
+ auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
739
+ break;
740
+ }
741
+
742
+ // ── MULTI-TARGET ──
743
+
744
+ case 'wp_set_target': {
745
+ validateInput(args, { site: { type: 'string', required: true } });
746
+ const { site } = args;
747
+ if (!targets[site]) {
748
+ const available = Object.keys(targets);
749
+ throw new Error(`Site "${site}" not found. Available: ${available.length > 0 ? available.join(', ') : 'none (configure WP_TARGETS_JSON or WP_TARGETS_FILE)'}`);
750
+ }
751
+ const prev = currentTarget?.name || 'default';
752
+ currentTarget = { name: site, ...targets[site] };
753
+ log.info(`Target switched: ${prev} → ${site} (${currentTarget.url})`);
754
+ result = json({ success: true, message: `Active site: ${site}`, previous: prev, current: { name: site, url: currentTarget.url } });
755
+ auditLog({ tool: name, action: 'switch_target', status: 'success', latency_ms: Date.now() - t0, params: { from: prev, to: site } });
756
+ break;
757
+ }
758
+
759
+ // ── SITE INFO ──
760
+
761
+ case 'wp_site_info': {
762
+ const { url: baseUrl, auth: activeAuth } = getActiveAuth();
763
+ const ctrl = new AbortController();
764
+ const tid = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
765
+ const resp = await fetch(`${baseUrl}/wp-json`, { headers: { 'Authorization': `Basic ${activeAuth}`, 'User-Agent': `WordPress-MCP-Server/${VERSION}` }, signal: ctrl.signal });
766
+ clearTimeout(tid);
767
+ const si = await resp.json();
768
+ const uResp = await fetch(`${baseUrl}/wp-json/wp/v2/users/me`, { headers: { 'Authorization': `Basic ${activeAuth}`, 'User-Agent': `WordPress-MCP-Server/${VERSION}` } });
769
+ const u = uResp.ok ? await uResp.json() : null;
770
+ let postTypes = [];
771
+ try { const t = await wpApiCall('/types'); postTypes = Object.values(t).map(x => ({ slug: x.slug, name: x.name, rest_base: x.rest_base })); } catch {}
772
+
773
+ result = json({
774
+ site: { name: si.name, description: si.description, url: si.url || baseUrl, gmt_offset: si.gmt_offset, timezone_string: si.timezone_string },
775
+ current_user: u ? { id: u.id, name: u.name, slug: u.slug, roles: u.roles } : null,
776
+ post_types: postTypes,
777
+ enterprise_controls: {
778
+ read_only: process.env.WP_READ_ONLY === 'true', draft_only: process.env.WP_DRAFT_ONLY === 'true', delete_disabled: process.env.WP_DISABLE_DELETE === 'true', plugin_management_disabled: process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true',
779
+ rate_limit: MAX_CALLS_PER_MINUTE > 0 ? `${MAX_CALLS_PER_MINUTE}/min` : 'unlimited',
780
+ allowed_types: ALLOWED_TYPES || 'all', allowed_statuses: ALLOWED_STATUSES || 'all',
781
+ audit_log: AUDIT_LOG
782
+ },
783
+ multi_target: {
784
+ enabled: isMultiTarget, active_site: currentTarget?.name || 'default',
785
+ available_sites: Object.keys(targets)
786
+ },
787
+ server: { mcp_version: VERSION, tools_count: TOOLS_COUNT }
788
+ });
789
+ auditLog({ tool: name, action: 'info', status: 'success', latency_ms: Date.now() - t0 });
790
+ break;
791
+ }
792
+
793
+ // ── SEO METADATA ──
794
+
795
+ case 'wp_get_seo_meta': {
796
+ validateInput(args, { id: { type: 'number', required: true, min: 1 } });
797
+ const { id, post_type = 'post' } = args;
798
+ const ep = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
799
+ const p = await wpApiCall(ep);
800
+ const meta = p.meta || {};
801
+ const yoastHead = p.yoast_head_json || null;
802
+
803
+ // Auto-detect SEO plugin and extract metadata
804
+ const seo = { plugin: 'none', title: null, description: null, focus_keyword: null, canonical: null, robots: { noindex: false, nofollow: false }, og_title: null, og_description: null, og_image: null, schema: null };
805
+
806
+ // Yoast SEO (most common)
807
+ if (meta._yoast_wpseo_title || meta._yoast_wpseo_metadesc || yoastHead) {
808
+ seo.plugin = 'yoast';
809
+ seo.title = meta._yoast_wpseo_title || yoastHead?.title || null;
810
+ seo.description = meta._yoast_wpseo_metadesc || yoastHead?.description || null;
811
+ seo.focus_keyword = meta._yoast_wpseo_focuskw || null;
812
+ seo.canonical = meta._yoast_wpseo_canonical || yoastHead?.canonical || null;
813
+ seo.robots.noindex = meta._yoast_wpseo_meta_robots_noindex === '1' || yoastHead?.robots?.index === 'noindex';
814
+ seo.robots.nofollow = meta._yoast_wpseo_meta_robots_nofollow === '1' || yoastHead?.robots?.follow === 'nofollow';
815
+ if (yoastHead?.og_title) seo.og_title = yoastHead.og_title;
816
+ if (yoastHead?.og_description) seo.og_description = yoastHead.og_description;
817
+ if (yoastHead?.og_image?.[0]?.url) seo.og_image = yoastHead.og_image[0].url;
818
+ if (yoastHead?.schema) seo.schema = yoastHead.schema;
819
+ }
820
+ // RankMath
821
+ else if (meta.rank_math_title || meta.rank_math_description) {
822
+ seo.plugin = 'rankmath';
823
+ seo.title = meta.rank_math_title || null;
824
+ seo.description = meta.rank_math_description || null;
825
+ seo.focus_keyword = meta.rank_math_focus_keyword || null;
826
+ seo.canonical = meta.rank_math_canonical_url || null;
827
+ const robots = meta.rank_math_robots || [];
828
+ seo.robots.noindex = Array.isArray(robots) ? robots.includes('noindex') : false;
829
+ seo.robots.nofollow = Array.isArray(robots) ? robots.includes('nofollow') : false;
830
+ seo.og_title = meta.rank_math_facebook_title || null;
831
+ seo.og_description = meta.rank_math_facebook_description || null;
832
+ seo.og_image = meta.rank_math_facebook_image || null;
833
+ }
834
+ // SEOPress
835
+ else if (meta._seopress_titles_title || meta._seopress_titles_desc) {
836
+ seo.plugin = 'seopress';
837
+ seo.title = meta._seopress_titles_title || null;
838
+ seo.description = meta._seopress_titles_desc || null;
839
+ seo.focus_keyword = meta._seopress_analysis_target_kw || null;
840
+ seo.canonical = meta._seopress_robots_canonical || null;
841
+ seo.robots.noindex = meta._seopress_robots_index === 'yes';
842
+ seo.robots.nofollow = meta._seopress_robots_follow === 'yes';
843
+ seo.og_title = meta._seopress_social_fb_title || null;
844
+ seo.og_description = meta._seopress_social_fb_desc || null;
845
+ seo.og_image = meta._seopress_social_fb_img || null;
846
+ }
847
+ // All in One SEO
848
+ else if (meta._aioseo_title || meta._aioseo_description) {
849
+ seo.plugin = 'aioseo';
850
+ seo.title = meta._aioseo_title || null;
851
+ seo.description = meta._aioseo_description || null;
852
+ seo.focus_keyword = meta._aioseo_keywords || null;
853
+ seo.canonical = meta._aioseo_canonical_url || null;
854
+ seo.robots.noindex = meta._aioseo_noindex === '1';
855
+ seo.robots.nofollow = meta._aioseo_nofollow === '1';
856
+ seo.og_title = meta._aioseo_og_title || null;
857
+ seo.og_description = meta._aioseo_og_description || null;
858
+ seo.og_image = meta._aioseo_og_image || null;
859
+ }
860
+
861
+ // Fallback: check raw meta keys for any SEO data
862
+ if (seo.plugin === 'none') {
863
+ const seoKeys = Object.keys(meta).filter(k => k.includes('seo') || k.includes('yoast') || k.includes('rank_math') || k.includes('aioseo') || k.includes('seopress'));
864
+ if (seoKeys.length > 0) {
865
+ seo.plugin = 'unknown';
866
+ seo.raw_keys = seoKeys;
867
+ }
868
+ }
869
+
870
+ result = json({
871
+ id: p.id, title: strip(p.title.rendered), slug: p.slug, link: p.link, status: p.status,
872
+ seo, all_meta_keys: Object.keys(meta)
873
+ });
874
+ auditLog({ tool: name, target: id, target_type: post_type, action: 'read_seo', status: 'success', latency_ms: Date.now() - t0 });
875
+ break;
876
+ }
877
+
878
+ case 'wp_update_seo_meta': {
879
+ validateInput(args, { id: { type: 'number', required: true, min: 1 } });
880
+ const { id, post_type = 'post', title, description, focus_keyword, canonical_url, robots_noindex, robots_nofollow } = args;
881
+
882
+ // First, detect which SEO plugin is installed by reading current meta
883
+ const readEp = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
884
+ const current = await wpApiCall(readEp);
885
+ const currentMeta = current.meta || {};
886
+ const yh = current.yoast_head_json || null;
887
+
888
+ let plugin = 'none';
889
+ if (currentMeta._yoast_wpseo_title !== undefined || currentMeta._yoast_wpseo_metadesc !== undefined || yh) plugin = 'yoast';
890
+ else if (currentMeta.rank_math_title !== undefined || currentMeta.rank_math_description !== undefined) plugin = 'rankmath';
891
+ else if (currentMeta._seopress_titles_title !== undefined) plugin = 'seopress';
892
+ else if (currentMeta._aioseo_title !== undefined) plugin = 'aioseo';
893
+
894
+ if (plugin === 'none') {
895
+ // Try to detect by checking if Yoast REST fields exist
896
+ if (yh) plugin = 'yoast';
897
+ else throw new Error('No SEO plugin detected. Install Yoast SEO, RankMath, SEOPress, or All in One SEO to manage SEO metadata.');
898
+ }
899
+
900
+ const metaUpdate = {};
901
+ const updated = [];
902
+
903
+ if (plugin === 'yoast') {
904
+ if (title !== undefined) { metaUpdate._yoast_wpseo_title = title; updated.push('title'); }
905
+ if (description !== undefined) { metaUpdate._yoast_wpseo_metadesc = description; updated.push('description'); }
906
+ if (focus_keyword !== undefined) { metaUpdate._yoast_wpseo_focuskw = focus_keyword; updated.push('focus_keyword'); }
907
+ if (canonical_url !== undefined) { metaUpdate._yoast_wpseo_canonical = canonical_url; updated.push('canonical'); }
908
+ if (robots_noindex !== undefined) { metaUpdate._yoast_wpseo_meta_robots_noindex = robots_noindex ? '1' : '0'; updated.push('noindex'); }
909
+ if (robots_nofollow !== undefined) { metaUpdate._yoast_wpseo_meta_robots_nofollow = robots_nofollow ? '1' : '0'; updated.push('nofollow'); }
910
+ } else if (plugin === 'rankmath') {
911
+ if (title !== undefined) { metaUpdate.rank_math_title = title; updated.push('title'); }
912
+ if (description !== undefined) { metaUpdate.rank_math_description = description; updated.push('description'); }
913
+ if (focus_keyword !== undefined) { metaUpdate.rank_math_focus_keyword = focus_keyword; updated.push('focus_keyword'); }
914
+ if (canonical_url !== undefined) { metaUpdate.rank_math_canonical_url = canonical_url; updated.push('canonical'); }
915
+ } else if (plugin === 'seopress') {
916
+ if (title !== undefined) { metaUpdate._seopress_titles_title = title; updated.push('title'); }
917
+ if (description !== undefined) { metaUpdate._seopress_titles_desc = description; updated.push('description'); }
918
+ if (focus_keyword !== undefined) { metaUpdate._seopress_analysis_target_kw = focus_keyword; updated.push('focus_keyword'); }
919
+ if (canonical_url !== undefined) { metaUpdate._seopress_robots_canonical = canonical_url; updated.push('canonical'); }
920
+ if (robots_noindex !== undefined) { metaUpdate._seopress_robots_index = robots_noindex ? 'yes' : ''; updated.push('noindex'); }
921
+ if (robots_nofollow !== undefined) { metaUpdate._seopress_robots_follow = robots_nofollow ? 'yes' : ''; updated.push('nofollow'); }
922
+ } else if (plugin === 'aioseo') {
923
+ if (title !== undefined) { metaUpdate._aioseo_title = title; updated.push('title'); }
924
+ if (description !== undefined) { metaUpdate._aioseo_description = description; updated.push('description'); }
925
+ if (focus_keyword !== undefined) { metaUpdate._aioseo_keywords = focus_keyword; updated.push('focus_keyword'); }
926
+ if (canonical_url !== undefined) { metaUpdate._aioseo_canonical_url = canonical_url; updated.push('canonical'); }
927
+ if (robots_noindex !== undefined) { metaUpdate._aioseo_noindex = robots_noindex ? '1' : '0'; updated.push('noindex'); }
928
+ if (robots_nofollow !== undefined) { metaUpdate._aioseo_nofollow = robots_nofollow ? '1' : '0'; updated.push('nofollow'); }
929
+ }
930
+
931
+ if (updated.length === 0) throw new Error('No SEO fields provided. Specify at least one of: title, description, focus_keyword, canonical_url, robots_noindex, robots_nofollow.');
932
+
933
+ const writeEp = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
934
+ await wpApiCall(writeEp, { method: 'POST', body: JSON.stringify({ meta: metaUpdate }) });
935
+
936
+ result = json({ success: true, message: `SEO meta updated for ${post_type} ${id}`, plugin, fields_updated: updated, meta_written: metaUpdate });
937
+ auditLog({ tool: name, target: id, target_type: post_type, action: 'update_seo', status: 'success', latency_ms: Date.now() - t0, params: { plugin, fields: updated } });
938
+ break;
939
+ }
940
+
941
+ case 'wp_audit_seo': {
942
+ const { post_type = 'post', per_page = 20, status = 'publish', orderby = 'date', order = 'desc' } = args;
943
+ const ep_base = post_type === 'page' ? '/pages' : '/posts';
944
+ const posts = await wpApiCall(`${ep_base}?per_page=${Math.min(per_page, 100)}&status=${status}&orderby=${orderby}&order=${order}`);
945
+
946
+ let seoPlugin = 'none';
947
+ const audit = [];
948
+ let totalScore = 0;
949
+
950
+ for (const p of posts) {
951
+ const meta = p.meta || {};
952
+ const yh = p.yoast_head_json || null;
953
+ const postTitle = strip(p.title?.rendered || '');
954
+ const item = { id: p.id, title: postTitle, slug: p.slug, link: p.link, seo_title: null, seo_description: null, focus_keyword: null, issues: [], score: 100 };
955
+
956
+ // Detect plugin on first post
957
+ if (seoPlugin === 'none') {
958
+ if (meta._yoast_wpseo_title !== undefined || meta._yoast_wpseo_metadesc !== undefined || yh) seoPlugin = 'yoast';
959
+ else if (meta.rank_math_title !== undefined) seoPlugin = 'rankmath';
960
+ else if (meta._seopress_titles_title !== undefined) seoPlugin = 'seopress';
961
+ else if (meta._aioseo_title !== undefined) seoPlugin = 'aioseo';
962
+ }
963
+
964
+ // Extract SEO fields based on detected plugin
965
+ if (seoPlugin === 'yoast') {
966
+ item.seo_title = meta._yoast_wpseo_title || yh?.title || null;
967
+ item.seo_description = meta._yoast_wpseo_metadesc || yh?.description || null;
968
+ item.focus_keyword = meta._yoast_wpseo_focuskw || null;
969
+ } else if (seoPlugin === 'rankmath') {
970
+ item.seo_title = meta.rank_math_title || null;
971
+ item.seo_description = meta.rank_math_description || null;
972
+ item.focus_keyword = meta.rank_math_focus_keyword || null;
973
+ } else if (seoPlugin === 'seopress') {
974
+ item.seo_title = meta._seopress_titles_title || null;
975
+ item.seo_description = meta._seopress_titles_desc || null;
976
+ item.focus_keyword = meta._seopress_analysis_target_kw || null;
977
+ } else if (seoPlugin === 'aioseo') {
978
+ item.seo_title = meta._aioseo_title || null;
979
+ item.seo_description = meta._aioseo_description || null;
980
+ item.focus_keyword = meta._aioseo_keywords || null;
981
+ }
982
+
983
+ // Quality checks
984
+ if (!item.seo_title) { item.issues.push('missing_seo_title'); item.score -= 30; }
985
+ else if (item.seo_title.length < 30) { item.issues.push('seo_title_too_short'); item.score -= 10; }
986
+ else if (item.seo_title.length > 60) { item.issues.push('seo_title_too_long'); item.score -= 10; }
987
+
988
+ if (!item.seo_description) { item.issues.push('missing_meta_description'); item.score -= 30; }
989
+ else if (item.seo_description.length < 120) { item.issues.push('meta_description_too_short'); item.score -= 10; }
990
+ else if (item.seo_description.length > 160) { item.issues.push('meta_description_too_long'); item.score -= 10; }
991
+
992
+ if (!item.focus_keyword) { item.issues.push('missing_focus_keyword'); item.score -= 20; }
993
+
994
+ if (item.seo_title && item.focus_keyword && !item.seo_title.toLowerCase().includes(item.focus_keyword.toLowerCase())) {
995
+ item.issues.push('keyword_not_in_title'); item.score -= 10;
996
+ }
997
+
998
+ if (item.score < 0) item.score = 0;
999
+ totalScore += item.score;
1000
+ audit.push(item);
1001
+ }
1002
+
1003
+ const avgScore = audit.length > 0 ? Math.round(totalScore / audit.length) : 0;
1004
+ const summary = {
1005
+ total_audited: audit.length,
1006
+ seo_plugin: seoPlugin,
1007
+ average_score: avgScore,
1008
+ issues_breakdown: {
1009
+ missing_seo_title: audit.filter(a => a.issues.includes('missing_seo_title')).length,
1010
+ missing_meta_description: audit.filter(a => a.issues.includes('missing_meta_description')).length,
1011
+ missing_focus_keyword: audit.filter(a => a.issues.includes('missing_focus_keyword')).length,
1012
+ title_length_issues: audit.filter(a => a.issues.includes('seo_title_too_short') || a.issues.includes('seo_title_too_long')).length,
1013
+ description_length_issues: audit.filter(a => a.issues.includes('meta_description_too_short') || a.issues.includes('meta_description_too_long')).length,
1014
+ keyword_not_in_title: audit.filter(a => a.issues.includes('keyword_not_in_title')).length
1015
+ }
1016
+ };
1017
+
1018
+ result = json({ summary, posts: audit });
1019
+ auditLog({ tool: name, action: 'audit_seo', status: 'success', latency_ms: Date.now() - t0, params: { post_type, count: audit.length, avg_score: avgScore } });
1020
+ break;
1021
+ }
1022
+
1023
+ // ── PLUGINS ──
1024
+
1025
+ case 'wp_list_plugins': {
1026
+ validateInput(args, {
1027
+ search: { type: 'string' },
1028
+ status: { type: 'string', enum: ['active', 'inactive', 'all'] },
1029
+ per_page: { type: 'number', min: 1, max: 100 }
1030
+ });
1031
+ const { search, status = 'all', per_page = 20 } = args;
1032
+ let ep = `/plugins?per_page=${per_page}&context=edit`;
1033
+ if (search) ep += `&search=${encodeURIComponent(search)}`;
1034
+ if (status && status !== 'all') ep += `&status=${status}`;
1035
+
1036
+ try {
1037
+ const plugins = await wpApiCall(ep);
1038
+ const mapped = plugins.map(p => ({
1039
+ plugin: p.plugin,
1040
+ name: p.name,
1041
+ version: p.version,
1042
+ status: p.status,
1043
+ author: p.author?.rendered ?? p.author ?? '',
1044
+ description: p.description?.rendered ? strip(p.description.rendered) : '',
1045
+ plugin_uri: p.plugin_uri ?? '',
1046
+ requires_wp: p.requires_wp ?? '',
1047
+ requires_php: p.requires_php ?? '',
1048
+ network_only: p.network_only ?? false,
1049
+ textdomain: p.textdomain ?? ''
1050
+ }));
1051
+ const activeCount = mapped.filter(p => p.status === 'active').length;
1052
+ const inactiveCount = mapped.filter(p => p.status === 'inactive').length;
1053
+ result = json({ total: mapped.length, active: activeCount, inactive: inactiveCount, plugins: mapped });
1054
+ auditLog({ tool: name, action: 'list', target_type: 'plugin', status: 'success', latency_ms: Date.now() - t0, params: { search, status, per_page } });
1055
+ } catch (pluginError) {
1056
+ const is403 = pluginError.message && (pluginError.message.includes('403') || pluginError.message.includes('Forbidden'));
1057
+ auditLog({ tool: name, action: 'list', target_type: 'plugin', status: 'error', latency_ms: Date.now() - t0, error: is403 ? 'Insufficient permissions' : pluginError.message, params: { search, status } });
1058
+ if (is403) {
1059
+ return { content: [{ type: 'text', text: 'Error: Access denied. wp_list_plugins requires Administrator role with activate_plugins capability. The current WordPress user does not have sufficient permissions to access the /wp/v2/plugins endpoint.' }], isError: true };
1060
+ }
1061
+ throw pluginError;
1062
+ }
1063
+ break;
1064
+ }
1065
+
1066
+ // ── PLUGINS (write) ──
1067
+
1068
+ case 'wp_activate_plugin': {
1069
+ validateInput(args, { plugin: { type: 'string', required: true } });
1070
+ const { plugin } = args;
1071
+ const encodedPlugin = encodeURIComponent(plugin);
1072
+ try {
1073
+ const p = await wpApiCall(`/plugins/${encodedPlugin}`, { method: 'POST', body: JSON.stringify({ status: 'active' }) });
1074
+ result = json({ success: true, message: `Plugin activated: ${p.name || plugin}`, plugin: { plugin: p.plugin, name: p.name, version: p.version, status: p.status } });
1075
+ auditLog({ tool: name, action: 'activate', target: plugin, target_type: 'plugin', status: 'success', latency_ms: Date.now() - t0 });
1076
+ } catch (pluginError) {
1077
+ const is403 = pluginError.message && (pluginError.message.includes('403') || pluginError.message.includes('Forbidden'));
1078
+ const is404 = pluginError.message && (pluginError.message.includes('404') || pluginError.message.includes('Not Found'));
1079
+ auditLog({ tool: name, action: 'activate', target: plugin, target_type: 'plugin', status: 'error', latency_ms: Date.now() - t0, error: is403 ? 'Insufficient permissions' : pluginError.message });
1080
+ if (is403) return { content: [{ type: 'text', text: 'Error: Access denied. wp_activate_plugin requires Administrator role with activate_plugins capability.' }], isError: true };
1081
+ if (is404) return { content: [{ type: 'text', text: 'Error: Plugin not found. Use wp_list_plugins to get the correct plugin slug.' }], isError: true };
1082
+ throw pluginError;
1083
+ }
1084
+ break;
1085
+ }
1086
+
1087
+ case 'wp_deactivate_plugin': {
1088
+ validateInput(args, { plugin: { type: 'string', required: true } });
1089
+ const { plugin } = args;
1090
+ const encodedPlugin = encodeURIComponent(plugin);
1091
+ try {
1092
+ const p = await wpApiCall(`/plugins/${encodedPlugin}`, { method: 'POST', body: JSON.stringify({ status: 'inactive' }) });
1093
+ result = json({ success: true, message: `Plugin deactivated: ${p.name || plugin}`, plugin: { plugin: p.plugin, name: p.name, version: p.version, status: p.status } });
1094
+ auditLog({ tool: name, action: 'deactivate', target: plugin, target_type: 'plugin', status: 'success', latency_ms: Date.now() - t0 });
1095
+ } catch (pluginError) {
1096
+ const is403 = pluginError.message && (pluginError.message.includes('403') || pluginError.message.includes('Forbidden'));
1097
+ const is404 = pluginError.message && (pluginError.message.includes('404') || pluginError.message.includes('Not Found'));
1098
+ auditLog({ tool: name, action: 'deactivate', target: plugin, target_type: 'plugin', status: 'error', latency_ms: Date.now() - t0, error: is403 ? 'Insufficient permissions' : pluginError.message });
1099
+ if (is403) return { content: [{ type: 'text', text: 'Error: Access denied. wp_deactivate_plugin requires Administrator role with activate_plugins capability.' }], isError: true };
1100
+ if (is404) return { content: [{ type: 'text', text: 'Error: Plugin not found. Use wp_list_plugins to get the correct plugin slug.' }], isError: true };
1101
+ throw pluginError;
1102
+ }
1103
+ break;
1104
+ }
1105
+
1106
+ // ── THEMES ──
1107
+
1108
+ case 'wp_list_themes': {
1109
+ validateInput(args, {
1110
+ status: { type: 'string', enum: ['active', 'inactive', 'all'] },
1111
+ per_page: { type: 'number', min: 1, max: 100 }
1112
+ });
1113
+ const { status = 'all', per_page = 20 } = args;
1114
+ let ep = `/themes?per_page=${per_page}&context=edit`;
1115
+ if (status && status !== 'all') ep += `&status=${status}`;
1116
+ try {
1117
+ const themes = await wpApiCall(ep);
1118
+ const mapped = themes.map(t => ({
1119
+ stylesheet: t.stylesheet,
1120
+ template: t.template,
1121
+ name: t.name?.rendered ?? t.name ?? '',
1122
+ description: t.description?.rendered ? strip(t.description.rendered) : '',
1123
+ status: t.status,
1124
+ version: t.version ?? '',
1125
+ author: t.author?.rendered ?? t.author ?? '',
1126
+ author_uri: t.author_uri ?? '',
1127
+ theme_uri: t.theme_uri ?? '',
1128
+ requires_wp: t.requires_wp ?? '',
1129
+ requires_php: t.requires_php ?? '',
1130
+ tags: t.tags?.rendered ?? t.tags ?? []
1131
+ }));
1132
+ const activeTheme = mapped.find(t => t.status === 'active');
1133
+ result = json({ total: mapped.length, active_theme: activeTheme ? activeTheme.name : null, themes: mapped });
1134
+ auditLog({ tool: name, action: 'list', target_type: 'theme', status: 'success', latency_ms: Date.now() - t0, params: { status, per_page } });
1135
+ } catch (themeError) {
1136
+ const is403 = themeError.message && (themeError.message.includes('403') || themeError.message.includes('Forbidden'));
1137
+ auditLog({ tool: name, action: 'list', target_type: 'theme', status: 'error', latency_ms: Date.now() - t0, error: is403 ? 'Insufficient permissions' : themeError.message, params: { status } });
1138
+ if (is403) return { content: [{ type: 'text', text: 'Error: Access denied. wp_list_themes requires switch_themes capability (Administrator or Editor role).' }], isError: true };
1139
+ throw themeError;
1140
+ }
1141
+ break;
1142
+ }
1143
+
1144
+ case 'wp_get_theme': {
1145
+ validateInput(args, { stylesheet: { type: 'string', required: true } });
1146
+ const { stylesheet } = args;
1147
+ try {
1148
+ const t = await wpApiCall(`/themes/${encodeURIComponent(stylesheet)}?context=edit`);
1149
+ result = json({
1150
+ stylesheet: t.stylesheet,
1151
+ template: t.template,
1152
+ name: t.name?.rendered ?? t.name ?? '',
1153
+ description: t.description?.rendered ? strip(t.description.rendered) : '',
1154
+ status: t.status,
1155
+ version: t.version ?? '',
1156
+ author: t.author?.rendered ?? t.author ?? '',
1157
+ author_uri: t.author_uri ?? '',
1158
+ theme_uri: t.theme_uri ?? '',
1159
+ requires_wp: t.requires_wp ?? '',
1160
+ requires_php: t.requires_php ?? '',
1161
+ tags: t.tags?.rendered ?? t.tags ?? []
1162
+ });
1163
+ auditLog({ tool: name, target: stylesheet, target_type: 'theme', action: 'read', status: 'success', latency_ms: Date.now() - t0 });
1164
+ } catch (themeError) {
1165
+ const is403 = themeError.message && (themeError.message.includes('403') || themeError.message.includes('Forbidden'));
1166
+ const is404 = themeError.message && (themeError.message.includes('404') || themeError.message.includes('Not Found'));
1167
+ auditLog({ tool: name, target: stylesheet, target_type: 'theme', action: 'read', status: 'error', latency_ms: Date.now() - t0, error: themeError.message });
1168
+ if (is404) return { content: [{ type: 'text', text: 'Error: Theme not found. Use wp_list_themes to get the correct stylesheet slug.' }], isError: true };
1169
+ if (is403) return { content: [{ type: 'text', text: 'Error: Access denied. wp_get_theme requires switch_themes capability (Administrator or Editor role).' }], isError: true };
1170
+ throw themeError;
1171
+ }
1172
+ break;
1173
+ }
1174
+
1175
+ // ── REVISIONS ──
1176
+
1177
+ case 'wp_list_revisions': {
1178
+ validateInput(args, {
1179
+ post_id: { type: 'number', required: true, min: 1 },
1180
+ post_type: { type: 'string', enum: ['post', 'page'] },
1181
+ per_page: { type: 'number', min: 1, max: 100 }
1182
+ });
1183
+ const { post_id, post_type = 'post', per_page = 10 } = args;
1184
+ const base = post_type === 'page' ? 'pages' : 'posts';
1185
+ try {
1186
+ const revisions = await wpApiCall(`/${base}/${post_id}/revisions?per_page=${per_page}&context=edit`);
1187
+ result = json({
1188
+ total: revisions.length,
1189
+ post_id,
1190
+ post_type,
1191
+ note: 'Use wp_get_revision to read content of a specific revision',
1192
+ revisions: revisions.map(r => ({
1193
+ id: r.id,
1194
+ parent: r.parent,
1195
+ date: r.date,
1196
+ date_gmt: r.date_gmt,
1197
+ modified: r.modified,
1198
+ modified_gmt: r.modified_gmt,
1199
+ author: r.author,
1200
+ title: r.title?.rendered ?? '',
1201
+ excerpt: r.excerpt?.rendered ? strip(r.excerpt.rendered) : '',
1202
+ slug: r.slug
1203
+ }))
1204
+ });
1205
+ auditLog({ tool: name, action: 'list', target: post_id, target_type: 'revision', status: 'success', latency_ms: Date.now() - t0, params: { post_type, per_page } });
1206
+ } catch (revError) {
1207
+ const is404 = revError.message && (revError.message.includes('404') || revError.message.includes('Not Found'));
1208
+ auditLog({ tool: name, action: 'list', target: post_id, target_type: 'revision', status: 'error', latency_ms: Date.now() - t0, error: revError.message });
1209
+ if (is404) return { content: [{ type: 'text', text: 'Error: Post not found or no revisions available. Revisions require autosave to be enabled.' }], isError: true };
1210
+ throw revError;
1211
+ }
1212
+ break;
1213
+ }
1214
+
1215
+ case 'wp_get_revision': {
1216
+ validateInput(args, {
1217
+ post_id: { type: 'number', required: true, min: 1 },
1218
+ revision_id: { type: 'number', required: true, min: 1 },
1219
+ post_type: { type: 'string', enum: ['post', 'page'] }
1220
+ });
1221
+ const { post_id, revision_id, post_type = 'post' } = args;
1222
+ const base = post_type === 'page' ? 'pages' : 'posts';
1223
+ try {
1224
+ const r = await wpApiCall(`/${base}/${post_id}/revisions/${revision_id}?context=edit`);
1225
+ result = json({
1226
+ id: r.id,
1227
+ parent: r.parent,
1228
+ date: r.date,
1229
+ author: r.author,
1230
+ title: r.title?.rendered ?? '',
1231
+ content: r.content?.rendered ?? '',
1232
+ excerpt: r.excerpt?.rendered ?? ''
1233
+ });
1234
+ auditLog({ tool: name, action: 'read', target: revision_id, target_type: 'revision', status: 'success', latency_ms: Date.now() - t0, params: { post_id, post_type } });
1235
+ } catch (revError) {
1236
+ const is404 = revError.message && (revError.message.includes('404') || revError.message.includes('Not Found'));
1237
+ auditLog({ tool: name, action: 'read', target: revision_id, target_type: 'revision', status: 'error', latency_ms: Date.now() - t0, error: revError.message });
1238
+ if (is404) return { content: [{ type: 'text', text: 'Error: Revision not found. Use wp_list_revisions to get valid revision IDs for this post.' }], isError: true };
1239
+ throw revError;
1240
+ }
1241
+ break;
1242
+ }
1243
+
1244
+ case 'wp_restore_revision': {
1245
+ validateInput(args, {
1246
+ post_id: { type: 'number', required: true, min: 1 },
1247
+ revision_id: { type: 'number', required: true, min: 1 },
1248
+ post_type: { type: 'string', enum: ['post', 'page'] }
1249
+ });
1250
+ const { post_id, revision_id, post_type = 'post' } = args;
1251
+ const base = post_type === 'page' ? 'pages' : 'posts';
1252
+
1253
+ // Step 1: Read the revision
1254
+ let revData;
1255
+ try {
1256
+ revData = await wpApiCall(`/${base}/${post_id}/revisions/${revision_id}?context=edit`);
1257
+ auditLog({ tool: name, action: 'read', target: revision_id, target_type: 'revision', status: 'success', latency_ms: Date.now() - t0, params: { post_id, post_type } });
1258
+ } catch (readError) {
1259
+ const is404 = readError.message && (readError.message.includes('404') || readError.message.includes('Not Found'));
1260
+ auditLog({ tool: name, action: 'read', target: revision_id, target_type: 'revision', status: 'error', latency_ms: Date.now() - t0, error: readError.message });
1261
+ if (is404) return { content: [{ type: 'text', text: 'Error: Revision not found. Use wp_list_revisions to get valid revision IDs.' }], isError: true };
1262
+ throw readError;
1263
+ }
1264
+
1265
+ // Step 2: Update the post with revision content
1266
+ const revTitle = revData.title?.raw ?? revData.title?.rendered ?? '';
1267
+ const revContent = revData.content?.raw ?? revData.content?.rendered ?? '';
1268
+ try {
1269
+ await wpApiCall(`/${base}/${post_id}`, { method: 'POST', body: JSON.stringify({ title: revTitle, content: revContent }) });
1270
+ result = json({ restored: true, post_id, revision_id, post_type, title: revTitle, note: `Post content restored from revision ${revision_id}` });
1271
+ auditLog({ tool: name, action: 'restore', target: post_id, target_type: post_type, status: 'success', latency_ms: Date.now() - t0, params: { revision_id } });
1272
+ } catch (writeError) {
1273
+ auditLog({ tool: name, action: 'restore', target: post_id, target_type: post_type, status: 'error', latency_ms: Date.now() - t0, error: writeError.message, params: { revision_id } });
1274
+ throw writeError;
1275
+ }
1276
+ break;
1277
+ }
1278
+
1279
+ case 'wp_delete_revision': {
1280
+ validateInput(args, {
1281
+ post_id: { type: 'number', required: true, min: 1 },
1282
+ revision_id: { type: 'number', required: true, min: 1 },
1283
+ post_type: { type: 'string', enum: ['post', 'page'] }
1284
+ });
1285
+ const { post_id, revision_id, post_type = 'post' } = args;
1286
+ const base = post_type === 'page' ? 'pages' : 'posts';
1287
+ try {
1288
+ await wpApiCall(`/${base}/${post_id}/revisions/${revision_id}?force=true`, { method: 'DELETE' });
1289
+ result = json({ deleted: true, revision_id, post_id, post_type });
1290
+ auditLog({ tool: name, action: 'permanent_delete', target: revision_id, target_type: 'revision', status: 'success', latency_ms: Date.now() - t0, params: { post_id, post_type } });
1291
+ } catch (delError) {
1292
+ const is403 = delError.message && (delError.message.includes('403') || delError.message.includes('Forbidden'));
1293
+ const is404 = delError.message && (delError.message.includes('404') || delError.message.includes('Not Found'));
1294
+ auditLog({ tool: name, action: 'permanent_delete', target: revision_id, target_type: 'revision', status: 'error', latency_ms: Date.now() - t0, error: delError.message, params: { post_id } });
1295
+ if (is404) return { content: [{ type: 'text', text: 'Error: Revision not found. Use wp_list_revisions to get valid revision IDs.' }], isError: true };
1296
+ if (is403) return { content: [{ type: 'text', text: 'Error: Insufficient permissions to delete revisions (delete_posts capability required).' }], isError: true };
1297
+ throw delError;
1298
+ }
1299
+ break;
1300
+ }
1301
+
1302
+ default:
1303
+ throw new Error(`Unknown tool: "${name}".`);
1304
+ }
1305
+
1306
+ log.debug(`${name} done in ${Date.now() - t0}ms`);
1307
+ return result;
1308
+
1309
+ } catch (error) {
1310
+ const ms = Date.now() - t0;
1311
+ log.error(`${name} failed (${ms}ms): ${error.message}`);
1312
+ auditLog({ tool: name, target: args.id || null, status: 'error', latency_ms: ms, params: sanitizeParams(args), error: error.message });
1313
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
1314
+ }
1315
+ }
1316
+
1317
+ // ============================================================
1318
+ // MCP SERVER FACTORY (for HTTP multi-session)
1319
+ // ============================================================
1320
+
1321
+ export function createMcpServer() {
1322
+ const s = new Server(
1323
+ { name: 'wordpress-mcp', version: VERSION },
1324
+ { capabilities: { tools: {} } }
1325
+ );
1326
+ registerHandlers(s);
1327
+ return s;
1328
+ }
1329
+
1330
+ // ============================================================
1331
+ // START
1332
+ // ============================================================
1333
+
1334
+ async function main() {
1335
+ await healthCheck();
1336
+
1337
+ const transportMode = (process.env.MCP_TRANSPORT || 'stdio').toLowerCase();
1338
+
1339
+ if (transportMode === 'http') {
1340
+ const manager = new HttpTransportManager();
1341
+ const port = parseInt(process.env.MCP_HTTP_PORT || '3000', 10);
1342
+ const host = process.env.MCP_HTTP_HOST || '0.0.0.0';
1343
+ const authToken = process.env.MCP_AUTH_TOKEN || null;
1344
+ const allowedOrigins = process.env.MCP_ALLOWED_ORIGINS
1345
+ ? process.env.MCP_ALLOWED_ORIGINS.split(',').map(s => s.trim()).filter(Boolean)
1346
+ : [];
1347
+ const sessionTimeoutMs = parseInt(process.env.MCP_SESSION_TIMEOUT_MS || '1800000', 10);
1348
+
1349
+ const httpServer = manager.createServer(createMcpServer, {
1350
+ port, host, authToken, allowedOrigins, sessionTimeoutMs,
1351
+ });
1352
+
1353
+ httpServer.listen(port, host, () => {
1354
+ log.info(`WordPress MCP Server (HTTP) ready — ${TOOLS_COUNT} tools on ${host}:${port}`);
1355
+ });
1356
+ } else {
1357
+ const transport = new StdioServerTransport();
1358
+ await server.connect(transport);
1359
+ log.info(`WordPress MCP Server ready — ${TOOLS_COUNT} tools`);
1360
+ }
1361
+ }
1362
+
1363
+ if (process.env.NODE_ENV !== 'test') {
1364
+ main().catch((error) => { log.error(`Fatal: ${error.message}`); process.exit(1); });
1365
+ }
1366
+
1367
+ export { server };