#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { logger } from './lib/utils.js'; import { cleanupExpiredTokens } from './lib/persistent-auth-config.js'; import { validateNodeVersion } from './lib/node-utils.js'; import { setupFetchPolyfill } from './lib/fetch-utils.js'; import { detectTransportType } from './lib/transport-detection.js'; import { createSessionContext, resolveInit } from './lib/session-utils.js'; import { createWrappedHandler, HANDLER_CONFIGS } from './lib/request-handler-factory.js'; import { MCP_WORDPRESS_REMOTE_VERSION } from './lib/config.js'; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, SetLevelRequestSchema, CompleteRequestSchema, ListRootsRequestSchema, } from './lib/mcp-types.js'; import { InitializeRequestSchema } from '@modelcontextprotocol/sdk/types.js'; validateNodeVersion(18); const ELEMENTOR_TOOLS = [ { name: 'wp_get_elementor_data', description: 'Lee la estructura Elementor resumida de una página WordPress desde _elementor_data.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página WordPress.' }, }, required: ['id'], }, }, { name: 'wp_get_elementor_data_raw', description: 'Devuelve _elementor_data completo sin truncar o el HTML crudo completo de un widget si se pasa widget_id.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página WordPress.' }, widget_id: { type: 'string', description: 'ID opcional del widget Elementor.' }, }, required: ['id'], }, }, { name: 'wp_init_elementor_page', description: 'Inicializa una página WordPress como página Elementor.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página WordPress.' }, template: { type: 'string', description: 'default, elementor_canvas o elementor_header_footer.' }, initial_data: { type: 'array', description: 'Estructura Elementor inicial opcional.' }, force: { type: 'boolean', description: 'Sobrescribir si ya tiene Elementor.' }, }, required: ['id'], }, }, { name: 'wp_update_elementor_widget', description: 'Actualiza un widget Elementor específico sin modificar el resto.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página.' }, widget_id: { type: 'string', description: 'ID del widget Elementor.' }, settings: { type: 'object', description: 'Settings a actualizar.' }, regenerate_css: { type: 'boolean', description: 'Regenerar CSS después.' }, }, required: ['id', 'widget_id', 'settings'], }, }, { name: 'wp_edit_widget_find_replace', description: 'Hace find/replace seguro dentro del HTML de un widget Elementor.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página.' }, widget_id: { type: 'string', description: 'ID del widget Elementor.' }, replacements: { type: 'array', description: 'Lista de objetos { find, replace, expect_count? }.' }, expect_count: { type: 'number', description: 'Cantidad esperada global por cada find.' }, setting_key: { type: 'string', description: 'Campo opcional de settings a editar. Ej: editor, html, title, text.' }, regenerate_css: { type: 'boolean', description: 'Regenerar CSS después.' }, }, required: ['id', 'widget_id', 'replacements'], }, }, { name: 'wp_update_elementor_data', description: 'Reemplaza completamente _elementor_data.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página.' }, elementor_data: { type: 'array', description: 'Estructura Elementor completa.' }, confirm_replace_all: { type: 'boolean', description: 'Debe ser true.' }, regenerate_css: { type: 'boolean', description: 'Regenerar CSS después.' }, }, required: ['id', 'elementor_data', 'confirm_replace_all'], }, }, { name: 'wp_move_section', description: 'Mueve una sección, widget o elemento Elementor dentro de su contenedor actual.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página.' }, element_id: { type: 'string', description: 'ID del elemento Elementor.' }, new_index: { type: 'number', description: 'Nueva posición 0-based.' }, regenerate_css: { type: 'boolean', description: 'Regenerar CSS después.' }, }, required: ['id', 'element_id', 'new_index'], }, }, { name: 'wp_add_section', description: 'Agrega una sección/container Elementor a nivel raíz.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página.' }, position: { type: 'number', description: 'Posición 0-based. -1 o ausente agrega al final.' }, element_json: { type: 'object', description: 'JSON Elementor de la sección/container.' }, regenerate_css: { type: 'boolean', description: 'Regenerar CSS después.' }, }, required: ['id', 'element_json'], }, }, { name: 'wp_add_widget', description: 'Agrega un widget Elementor dentro de una columna/container padre.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página.' }, parent_id: { type: 'string', description: 'ID de la columna/container padre.' }, position: { type: 'number', description: 'Posición 0-based. -1 o ausente agrega al final.' }, element_json: { type: 'object', description: 'JSON Elementor del widget.' }, regenerate_css: { type: 'boolean', description: 'Regenerar CSS después.' }, }, required: ['id', 'parent_id', 'element_json'], }, }, { name: 'wp_delete_element', description: 'Elimina una sección, columna, container o widget Elementor por ID.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página.' }, element_id: { type: 'string', description: 'ID del elemento a eliminar.' }, regenerate_css: { type: 'boolean', description: 'Regenerar CSS después.' }, }, required: ['id', 'element_id'], }, }, { name: 'wp_list_elementor_backups', description: 'Lista backups automáticos Elementor.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página.' }, }, required: ['id'], }, }, { name: 'wp_restore_elementor_backup', description: 'Restaura un backup Elementor.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página.' }, backup_key: { type: 'string', description: 'Clave del backup.' }, regenerate_css: { type: 'boolean', description: 'Regenerar CSS después.' }, }, required: ['id', 'backup_key'], }, }, { name: 'wp_snapshot_page', description: 'Crea snapshot manual etiquetado de _elementor_data.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la página.' }, label: { type: 'string', description: 'Etiqueta opcional del snapshot.' }, }, required: ['id'], }, }, { name: 'wp_regenerate_elementor_css', description: 'Regenera CSS Elementor.', inputSchema: { type: 'object', properties: { scope: { type: 'string', enum: ['page', 'all'] }, page_id: { type: 'number' }, }, required: ['scope'], }, }, { name: 'wp_upload_media_from_url', description: 'Sube una imagen a la biblioteca de medios de WordPress descargándola desde una URL pública HTTPS.', inputSchema: { type: 'object', properties: { source_url: { type: 'string', description: 'URL pública HTTPS de la imagen origen.' }, alt_text: { type: 'string', description: 'Texto alternativo de la imagen.' }, title: { type: 'string', description: 'Título del archivo en WordPress.' }, caption: { type: 'string', description: 'Leyenda/caption del medio.' }, filename: { type: 'string', description: 'Nombre de archivo sugerido, por ejemplo imagen-blog.jpg.' }, }, required: ['source_url'], }, }, { name: 'wp_add_cpt', description: 'Crea una entrada, página o CPT (incluye pauple_helpie) por REST core, RESPETANDO el status pedido (default draft) y VALIDANDO el post_type contra los tipos registrados/visibles por REST. Crea un placeholder liviano: el contenido rico en crudo (style/script/JSON-LD) se setea después con wp_set_post_content. Aborta sin crear si el post_type no existe o no es REST-visible.', inputSchema: { type: 'object', properties: { post_type: { type: 'string', description: 'Colección REST del tipo de contenido. Default: posts. Ej: posts, pages, pauple_helpie.' }, title: { type: 'string', description: 'Título del contenido.' }, content: { type: 'string', description: 'Contenido inicial opcional (REST lo sanitiza; para HTML crudo con style/script/JSON-LD usar wp_set_post_content después).' }, excerpt: { type: 'string', description: 'Extracto.' }, status: { type: 'string', description: 'Estado: draft (default), publish, pending, private, etc.' }, slug: { type: 'string', description: 'Slug del contenido.' }, meta: { type: 'object', description: 'Pares clave→valor de post-meta expuestos por REST. Ej: rank_math_title, rank_math_description, rank_math_focus_keyword.', additionalProperties: true, }, }, required: ['title'], }, }, { name: 'wp_get_cpt', description: 'Obtiene un post/CPT por ID desde WordPress incluyendo meta REST editable, útil para verificar Rank Math. Soporta response="minimal" para traer solo metadata sin el content/_elementor_data.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID del post, página o CPT.' }, post_type: { type: 'string', description: 'Colección REST del tipo de contenido. Default: posts. Ej: posts, pages, productos.' }, response: { type: 'string', enum: ['full', 'minimal'], description: 'full (default) devuelve el objeto completo como hasta ahora. minimal devuelve solo id, slug, status, title, modified, link y meta, SIN content ni _elementor_data (ideal para verificar metadata/Rank Math sin inflar el contexto).' }, fields: { type: 'array', description: 'Campos REST puntuales a devolver, ej: ["id","status","meta"]. Si se pasa, tiene prioridad sobre response. id se incluye siempre.' }, }, required: ['id'], }, }, { name: 'wp_update_cpt', description: 'Actualiza un post/CPT por ID y permite enviar meta REST, incluyendo campos SEO de Rank Math.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID del post, página o CPT.' }, post_type: { type: 'string', description: 'Colección REST del tipo de contenido. Default: posts. Ej: posts, pages, productos.' }, title: { type: 'string', description: 'Título del contenido.' }, content: { type: 'string', description: 'Contenido HTML/Gutenberg.' }, excerpt: { type: 'string', description: 'Extracto.' }, status: { type: 'string', description: 'Estado: draft, publish, pending, etc.' }, slug: { type: 'string', description: 'Slug del contenido.' }, meta: { type: 'object', description: 'Pares clave→valor de post-meta expuestos por REST. Ej: rank_math_title, rank_math_description, rank_math_focus_keyword.', additionalProperties: true, }, response: { type: 'string', enum: ['full', 'minimal'], description: 'full (default) devuelve el objeto completo como hasta ahora. minimal devuelve solo id, slug, status, title, modified, link (+ meta si se envió meta), SIN content ni _elementor_data. Recomendado en ediciones en lote (cambios de status, slug o meta) para no inflar el contexto.' }, fields: { type: 'array', description: 'Campos REST puntuales a devolver en la respuesta, ej: ["id","status"]. Si se pasa, tiene prioridad sobre response. id se incluye siempre.' }, }, required: ['id'], }, }, { name: 'wp_delete_cpt', description: 'Borra una entrada, página, CPT o medio por ID desde WordPress (REST core). Por defecto envía a la PAPELERA (reversible). Con force=true elimina DEFINITIVAMENTE (irreversible) y requiere confirmar=true. Los medios (post_type=media) no tienen papelera: su borrado es siempre definitivo y también requiere confirmar=true. Devuelve un resumen (id, acción, estado), no el objeto completo.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID del post, página, CPT o medio a borrar.' }, post_type: { type: 'string', description: 'Colección REST del tipo de contenido. Default: posts. Ej: posts, pages, productos, media.' }, force: { type: 'boolean', description: 'false (default) = enviar a papelera (reversible). true = borrado definitivo (irreversible); requiere confirmar=true.' }, confirmar: { type: 'boolean', description: 'Debe ser true para cualquier borrado definitivo (force=true, o post_type=media). No es necesario para enviar a papelera.' }, }, required: ['id'], }, }, { name: 'wp_flush_cache', description: 'Purga caché de Elementor y LiteSpeed desde el plugin Wynges MCP. Puede purgar una página específica o todo el sitio.', inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'ID de la página/entrada a purgar. Si se omite o es 0, purga todo.', }, }, required: [], }, }, { name: 'wp_crear_redireccion', description: 'Crea una redirección Rank Math en WordPress usando el endpoint Wynges.', inputSchema: { type: 'object', properties: { origen: { type: 'string', description: 'Ruta o URL de origen. Ej: /software-para-kioscos/', }, destino: { type: 'string', description: 'URL completa de destino. Ej: https://wynges.com/', }, codigo: { type: 'number', description: 'Código HTTP de redirección. Default: 301.', }, }, required: ['origen', 'destino'], }, }, { name: 'wp_listar_redirecciones', description: 'Lista redirecciones Rank Math desde WordPress, incluyendo hits y último acceso.', inputSchema: { type: 'object', properties: { buscar: { type: 'string', description: 'Texto para filtrar redirecciones.', }, estado: { type: 'string', enum: ['active', 'inactive', 'trashed', 'any'], description: 'Estado de la redirección. Default: any.', }, limit: { type: 'number', description: 'Cantidad por página. Default: 50, máximo: 200.', }, pagina: { type: 'number', description: 'Página de resultados. Default: 1.', }, }, required: [], }, }, { name: 'wp_eliminar_redireccion', description: 'Elimina una redirección Rank Math por ID. Requiere confirmar=true porque es una acción destructiva.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID de la redirección.', }, confirmar: { type: 'boolean', description: 'Debe ser true para ejecutar la eliminación.', }, }, required: ['id', 'confirmar'], }, }, { name: 'wp_post_find_replace', description: 'Find/replace quirúrgico sobre el post_content Gutenberg de un post o CPT, sin reenviar todo el contenido. Valida expect_count, hace backup automático previo y protege la integridad de los bloques Gutenberg y de los JSON-LD (HowTo/FAQPage/Article). Aborta sin escribir si algo no cuadra. Equivalente a wp_edit_widget_find_replace pero para blogs Gutenberg.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID del post o CPT (Gutenberg).' }, replacements: { type: 'array', description: 'Lista de objetos { find, replace, expect_count? }.' }, expect_count: { type: 'number', description: 'Cantidad esperada global de coincidencias por cada find. Si no coincide, no aplica nada.' }, dry_run: { type: 'boolean', description: 'Si es true, previsualiza y valida los cambios pero NO escribe ni hace backup. Default: false.' }, allow_block_count_change: { type: 'boolean', description: 'Permite que cambie la cantidad de delimitadores de bloque Gutenberg (. Edición multi-bloque sin reescribir toda la página. Con backup, validación de bloques+JSON-LD y verificación read-after-write. Falla si la sección ya existe (usar wp_replace_block).', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID del post/página/CPT.' }, name: { type: 'string', description: 'Nombre único de la sección (marcador LG-SEC). Letras, números, guion y guion bajo. Ej: hero, precio, cta.' }, html: { type: 'string', description: 'HTML de la sección. Por defecto se envuelve en un bloque wp:html (ver wrap).' }, position: { type: 'string', enum: ['start', 'end'], description: 'Dónde insertar si no se usa after_marker/before_marker. Default: end.' }, after_marker: { type: 'string', description: 'Insertar inmediatamente DESPUÉS de esta sección LG-SEC existente (tiene prioridad sobre position).' }, before_marker: { type: 'string', description: 'Insertar inmediatamente ANTES de esta sección LG-SEC existente (tiene prioridad sobre position).' }, wrap: { type: 'boolean', description: 'true (default) envuelve el html en un bloque wp:html. false: el html ya trae sus propios bloques wp:.' }, flush_cache: { type: 'boolean', description: 'Purgar caché tras escribir. Default: true.' }, dry_run: { type: 'boolean', description: 'Previsualiza y valida sin escribir. Default: false.' }, }, required: ['id', 'name', 'html'], }, }, { name: 'wp_delete_block', description: 'Elimina por completo una sección LG-SEC (marcadores + contenido) de un post Gutenberg. Con backup, validación de integridad y verificación read-after-write.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID del post/página/CPT.' }, name: { type: 'string', description: 'Nombre de la sección LG-SEC a eliminar.' }, flush_cache: { type: 'boolean', description: 'Purgar caché tras escribir. Default: true.' }, dry_run: { type: 'boolean', description: 'Previsualiza y valida sin escribir. Default: false.' }, }, required: ['id', 'name'], }, }, { name: 'wp_move_block', description: 'Reordena una sección LG-SEC existente SIN cambiar su contenido. Verifica que la cantidad de bloques y JSON-LD no cambie (integridad). Con backup y verificación read-after-write.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID del post/página/CPT.' }, name: { type: 'string', description: 'Nombre de la sección LG-SEC a mover.' }, position: { type: 'string', enum: ['start', 'end'], description: 'Mover al inicio o al final si no se usa after_marker/before_marker. Default: end.' }, after_marker: { type: 'string', description: 'Reubicar inmediatamente DESPUÉS de esta otra sección (prioridad sobre position).' }, before_marker: { type: 'string', description: 'Reubicar inmediatamente ANTES de esta otra sección (prioridad sobre position).' }, flush_cache: { type: 'boolean', description: 'Purgar caché tras escribir. Default: true.' }, dry_run: { type: 'boolean', description: 'Previsualiza y valida sin escribir. Default: false.' }, }, required: ['id', 'name'], }, }, { name: 'wp_replace_block', description: 'Reemplaza el contenido de una sección LG-SEC existente, preservando sus marcadores. Editás una banda puntual (ej. LG-SEC:precio) sin tocar el resto de la página. Con backup, validación de bloques+JSON-LD y verificación read-after-write.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'ID del post/página/CPT.' }, name: { type: 'string', description: 'Nombre de la sección LG-SEC a reemplazar. Debe existir.' }, html: { type: 'string', description: 'Nuevo HTML de la sección. Por defecto se envuelve en un bloque wp:html (ver wrap).' }, wrap: { type: 'boolean', description: 'true (default) envuelve el html en un bloque wp:html. false: el html ya trae sus propios bloques wp:.' }, flush_cache: { type: 'boolean', description: 'Purgar caché tras escribir. Default: true.' }, dry_run: { type: 'boolean', description: 'Previsualiza y valida sin escribir. Default: false.' }, }, required: ['id', 'name', 'html'], }, }, ]; const ELEMENTOR_TOOL_NAMES = new Set(ELEMENTOR_TOOLS.map(tool => tool.name)); function getWpBaseUrl() { const raw = process.env.WP_API_URL || ''; if (!raw) { throw new Error('Falta WP_API_URL'); } return raw.replace(/\/$/, ''); } function getWpAuthHeader() { const user = process.env.WP_API_USERNAME || ''; const pass = process.env.WP_API_PASSWORD || ''; if (!user || !pass) { throw new Error('Faltan credenciales WordPress'); } const token = Buffer.from(`${user}:${pass}`, 'utf8').toString('base64'); return `Basic ${token}`; } async function callWyngesElementorEndpoint(path: string, options: any = {}) { const baseUrl = getWpBaseUrl(); const url = `${baseUrl}/wp-json/wynges-mcp/v1${path}`; const response = await fetch(url, { ...options, headers: { Authorization: getWpAuthHeader(), 'Content-Type': 'application/json', Accept: 'application/json', ...(options.headers || {}), }, }); const text = await response.text(); let data: any; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } if (!response.ok) { throw new Error(`WordPress REST ${response.status}: ${JSON.stringify(data)}`); } return data; } // Tarea #88: respuesta "slim" opcional para wp_get_cpt / wp_update_cpt. // Usa el parámetro nativo _fields de la REST core de WordPress para que el server // ni serialice content/_elementor_data. Devuelve el CSV de campos (ya url-encodeados) // o '' cuando corresponde la respuesta completa (full = comportamiento de siempre). // Normaliza `fields` con tolerancia: acepta array (["id","status"]), // CSV string ("id,status") o string JSON ('["id","status"]'). Esto cubre el caso // en que el cliente MCP serializa el array como string cuando el schema aún no // está sincronizado. function normalizeFieldsArg(raw: any): string[] { if (raw === undefined || raw === null) return []; let arr: any = raw; if (typeof raw === 'string') { const s = raw.trim(); if (!s) return []; if (s.startsWith('[')) { try { arr = JSON.parse(s); } catch { arr = s.split(','); } } else { arr = s.split(','); } } if (!Array.isArray(arr)) return []; return arr .map((f: any) => String(f).trim()) .filter((f: string) => f.length > 0); } function buildCoreFieldsCsv(args: any, isGet: boolean): string { let fields: string[] = []; // fields=[...] explícito tiene prioridad sobre response const explicit = normalizeFieldsArg(args?.fields); if (explicit.length > 0) { fields = explicit; if (!fields.includes('id')) fields.unshift('id'); } else if (String(args?.response || 'full').toLowerCase() === 'minimal') { // Preset minimal: campos clave, sin content ni _elementor_data fields = ['id', 'slug', 'status', 'title', 'modified', 'link']; // meta: siempre en get (caso verificar Rank Math); en update solo si se envió meta if (isGet || args?.meta !== undefined) fields.push('meta'); } if (fields.length === 0) return ''; return fields.map((f: string) => encodeURIComponent(f)).join(','); } function getWpRestCollection(postType: any) { const raw = String(postType || 'posts').trim().replace(/^\/+|\/+$/g, ''); if (!raw || raw === 'post') return 'posts'; if (raw === 'page') return 'pages'; return raw; } async function callWordPressCoreEndpoint(path: string, options: any = {}) { const baseUrl = getWpBaseUrl(); const url = `${baseUrl}/wp-json/wp/v2${path}`; const response = await fetch(url, { ...options, headers: { Authorization: getWpAuthHeader(), 'Content-Type': 'application/json', Accept: 'application/json', ...(options.headers || {}), }, }); const text = await response.text(); let data: any; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } if (!response.ok) { throw new Error(`WordPress Core REST ${response.status}: ${JSON.stringify(data)}`); } return data; } async function callWyngesRedirectEndpoint(path: string, options: any = {}) { const baseUrl = getWpBaseUrl(); const url = `${baseUrl}/wp-json/wynges/v1${path}`; const response = await fetch(url, { ...options, headers: { Authorization: getWpAuthHeader(), 'Content-Type': 'application/json', Accept: 'application/json', ...(options.headers || {}), }, }); const text = await response.text(); let data: any; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } if (!response.ok) { throw new Error(`WordPress Redirecciones REST ${response.status}: ${JSON.stringify(data)}`); } return data; } async function handleElementorTool(name: string, args: any) { let result: any; if (name === 'wp_get_elementor_data') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); result = await callWyngesElementorEndpoint(`/elementor/${id}`, { method: 'GET', }); } if (name === 'wp_get_elementor_data_raw') { const id = Number(args?.id); const widgetId = args?.widget_id ? String(args.widget_id) : ''; if (!id) throw new Error('id requerido'); const nocache = Date.now(); result = await callWyngesElementorEndpoint(`/elementor/${id}/raw?_nocache=${nocache}`, { method: 'POST', body: JSON.stringify({ widget_id: widgetId || undefined, _nocache: nocache, }), headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', Pragma: 'no-cache', Expires: '0', 'X-Wynges-MCP-NoCache': String(nocache), }, }); } if (name === 'wp_init_elementor_page') { const id = Number(args?.id); const template = String(args?.template || 'default'); const initialData = args?.initial_data ?? []; const force = Boolean(args?.force); if (!id) throw new Error('id requerido'); result = await callWyngesElementorEndpoint(`/elementor/${id}/init`, { method: 'POST', body: JSON.stringify({ template, initial_data: initialData, force, }), }); } if (name === 'wp_update_elementor_widget') { const id = Number(args?.id); const widgetId = String(args?.widget_id || ''); const settings = args?.settings; const regenerateCss = args?.regenerate_css ?? true; if (!id) throw new Error('id requerido'); if (!widgetId) throw new Error('widget_id requerido'); result = await callWyngesElementorEndpoint(`/elementor/${id}/widget`, { method: 'POST', body: JSON.stringify({ widget_id: widgetId, settings, regenerate_css: regenerateCss, }), }); } if (name === 'wp_edit_widget_find_replace') { const id = Number(args?.id); const widgetId = String(args?.widget_id || ''); if (!id) throw new Error('id requerido'); if (!widgetId) throw new Error('widget_id requerido'); result = await callWyngesElementorEndpoint(`/elementor/${id}/widget/find-replace`, { method: 'POST', body: JSON.stringify({ widget_id: widgetId, replacements: args?.replacements, expect_count: args?.expect_count, setting_key: args?.setting_key, regenerate_css: args?.regenerate_css ?? true, }), }); } if (name === 'wp_update_elementor_data') { const id = Number(args?.id); const elementorData = args?.elementor_data; const confirm = Boolean(args?.confirm_replace_all); const regenerateCss = args?.regenerate_css ?? true; if (!id) throw new Error('id requerido'); if (!Array.isArray(elementorData)) { throw new Error('elementor_data debe ser array'); } result = await callWyngesElementorEndpoint(`/elementor/${id}/data`, { method: 'POST', body: JSON.stringify({ elementor_data: elementorData, confirm_replace_all: confirm, regenerate_css: regenerateCss, }), }); } if (name === 'wp_move_section') { const id = Number(args?.id); const elementId = String(args?.element_id || ''); if (!id) throw new Error('id requerido'); if (!elementId) throw new Error('element_id requerido'); result = await callWyngesElementorEndpoint(`/elementor/${id}/move`, { method: 'POST', body: JSON.stringify({ element_id: elementId, new_index: args?.new_index, regenerate_css: args?.regenerate_css ?? true, }), }); } if (name === 'wp_add_section') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); result = await callWyngesElementorEndpoint(`/elementor/${id}/add-section`, { method: 'POST', body: JSON.stringify({ position: args?.position ?? -1, element_json: args?.element_json, regenerate_css: args?.regenerate_css ?? true, }), }); } if (name === 'wp_add_widget') { const id = Number(args?.id); const parentId = String(args?.parent_id || ''); if (!id) throw new Error('id requerido'); if (!parentId) throw new Error('parent_id requerido'); result = await callWyngesElementorEndpoint(`/elementor/${id}/add-widget`, { method: 'POST', body: JSON.stringify({ parent_id: parentId, position: args?.position ?? -1, element_json: args?.element_json, regenerate_css: args?.regenerate_css ?? true, }), }); } if (name === 'wp_delete_element') { const id = Number(args?.id); const elementId = String(args?.element_id || ''); if (!id) throw new Error('id requerido'); if (!elementId) throw new Error('element_id requerido'); result = await callWyngesElementorEndpoint(`/elementor/${id}/delete-element`, { method: 'POST', body: JSON.stringify({ element_id: elementId, regenerate_css: args?.regenerate_css ?? true, }), }); } if (name === 'wp_list_elementor_backups') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); result = await callWyngesElementorEndpoint(`/elementor/${id}/backups`, { method: 'GET', }); } if (name === 'wp_restore_elementor_backup') { const id = Number(args?.id); const backupKey = String(args?.backup_key || ''); const regenerateCss = args?.regenerate_css ?? true; if (!id) throw new Error('id requerido'); if (!backupKey) throw new Error('backup_key requerido'); result = await callWyngesElementorEndpoint(`/elementor/${id}/restore-backup`, { method: 'POST', body: JSON.stringify({ backup_key: backupKey, regenerate_css: regenerateCss, }), }); } if (name === 'wp_snapshot_page') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); result = await callWyngesElementorEndpoint(`/elementor/${id}/snapshot`, { method: 'POST', body: JSON.stringify({ label: args?.label || '', }), }); } if (name === 'wp_regenerate_elementor_css') { const scope = String(args?.scope || ''); const pageId = args?.page_id ? Number(args.page_id) : undefined; result = await callWyngesElementorEndpoint(`/elementor/regenerate-css`, { method: 'POST', body: JSON.stringify({ scope, page_id: pageId, }), }); } if (name === 'wp_upload_media_from_url') { const sourceUrl = String(args?.source_url || '').trim(); if (!sourceUrl) throw new Error('source_url requerido'); result = await callWyngesElementorEndpoint(`/media/from-url`, { method: 'POST', body: JSON.stringify({ source_url: sourceUrl, alt_text: args?.alt_text || '', title: args?.title || '', caption: args?.caption || '', filename: args?.filename || '', }), }); } if (name === 'wp_get_cpt') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); const collection = getWpRestCollection(args?.post_type); const fieldsCsv = buildCoreFieldsCsv(args, true); const query = fieldsCsv ? `?context=edit&_fields=${fieldsCsv}` : `?context=edit`; result = await callWordPressCoreEndpoint(`/${collection}/${id}${query}`, { method: 'GET', }); } if (name === 'wp_add_cpt') { const title = args?.title; if (title === undefined || String(title).trim() === '') { throw new Error('title requerido'); } const collection = getWpRestCollection(args?.post_type); // Punto 15 — validar post_type: si la colección no está registrada/REST-visible, abortar // (evita crear huérfanos con post_type literal como "pages"). const types: any = await callWordPressCoreEndpoint(`/types?context=edit`, { method: 'GET', }); const restBases = Object.values(types || {}) .map((t: any) => t?.rest_base) .filter(Boolean); if (!restBases.includes(collection)) { throw new Error( `post_type_invalido: '${args?.post_type ?? 'posts'}' (colección REST '${collection}') ` + `no está registrado o no es visible por REST. Colecciones válidas: ${restBases.join(', ')}` ); } // Punto 14 — respetar el status pedido (default draft, más seguro para autoría). const body: any = { title, status: args?.status !== undefined ? args.status : 'draft', }; if (args?.content !== undefined) body.content = args.content; if (args?.excerpt !== undefined) body.excerpt = args.excerpt; if (args?.slug !== undefined) body.slug = args.slug; if (args?.meta !== undefined) { if (args.meta === null || Array.isArray(args.meta) || typeof args.meta !== 'object') { throw new Error('meta debe ser un objeto clave→valor'); } body.meta = args.meta; } result = await callWordPressCoreEndpoint(`/${collection}`, { method: 'POST', body: JSON.stringify(body), }); } if (name === 'wp_update_cpt') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); const collection = getWpRestCollection(args?.post_type); const body: any = {}; if (args?.title !== undefined) body.title = args.title; if (args?.content !== undefined) body.content = args.content; if (args?.excerpt !== undefined) body.excerpt = args.excerpt; if (args?.status !== undefined) body.status = args.status; if (args?.slug !== undefined) body.slug = args.slug; if (args?.meta !== undefined) { if (args.meta === null || Array.isArray(args.meta) || typeof args.meta !== 'object') { throw new Error('meta debe ser un objeto clave→valor'); } body.meta = args.meta; } if (Object.keys(body).length === 0) { throw new Error('Debe enviar al menos un campo para actualizar: title, content, excerpt, status, slug o meta'); } const fieldsCsv = buildCoreFieldsCsv(args, false); const query = fieldsCsv ? `?_fields=${fieldsCsv}` : ''; result = await callWordPressCoreEndpoint(`/${collection}/${id}${query}`, { method: 'POST', body: JSON.stringify(body), }); } if (name === 'wp_delete_cpt') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); const collection = getWpRestCollection(args?.post_type); const isMedia = collection === 'media'; // Los medios no tienen papelera: su borrado es siempre definitivo. const definitivo = args?.force === true || isMedia; if (definitivo && args?.confirmar !== true) { throw new Error( isMedia ? 'Los medios no tienen papelera: el borrado es definitivo e irreversible. Pasá confirmar=true para ejecutarlo.' : 'Borrado definitivo (force=true) es irreversible. Pasá confirmar=true para ejecutarlo, u omití force para enviar a la papelera.' ); } const query = definitivo ? '?force=true' : ''; const data: any = await callWordPressCoreEndpoint(`/${collection}/${id}${query}`, { method: 'DELETE', }); // Respuesta resumida (no devolvemos el objeto completo con content/_elementor_data). if (definitivo) { result = { ok: true, id, post_type: collection, accion: 'eliminado_definitivo', deleted: data?.deleted === true, status_previo: data?.previous?.status ?? null, }; } else { result = { ok: true, id, post_type: collection, accion: 'papelera', status: data?.status ?? 'trash', }; } } if (name === 'wp_flush_cache') { const postId = args?.post_id !== undefined ? Number(args.post_id) : 0; result = await callWyngesElementorEndpoint(`/flush-cache`, { method: 'POST', body: JSON.stringify({ post_id: Number.isFinite(postId) ? postId : 0, }), }); } if (name === 'wp_crear_redireccion') { const origen = String(args?.origen || '').trim(); const destino = String(args?.destino || '').trim(); const codigoRaw = args?.codigo !== undefined ? Number(args.codigo) : 301; const codigo = Number.isFinite(codigoRaw) ? codigoRaw : 301; if (!origen) throw new Error('origen requerido'); if (!destino) throw new Error('destino requerido'); result = await callWyngesRedirectEndpoint(`/redirecciones`, { method: 'POST', body: JSON.stringify({ origen, destino, codigo, }), }); } if (name === 'wp_listar_redirecciones') { const params = new URLSearchParams(); if (args?.buscar !== undefined && String(args.buscar).trim()) { params.set('buscar', String(args.buscar).trim()); } if (args?.estado !== undefined && String(args.estado).trim()) { params.set('estado', String(args.estado).trim()); } if (args?.limit !== undefined) { const limit = Number(args.limit); if (Number.isFinite(limit)) { params.set('limit', String(limit)); } } if (args?.pagina !== undefined) { const pagina = Number(args.pagina); if (Number.isFinite(pagina)) { params.set('pagina', String(pagina)); } } const query = params.toString(); result = await callWyngesRedirectEndpoint(`/redirecciones${query ? `?${query}` : ''}`, { method: 'GET', }); } if (name === 'wp_eliminar_redireccion') { const id = Number(args?.id); const confirmar = args?.confirmar === true; if (!id) throw new Error('id requerido'); if (!confirmar) { throw new Error('confirmar=true requerido para eliminar una redirección. Acción destructiva no ejecutada.'); } result = await callWyngesRedirectEndpoint(`/redirecciones/${encodeURIComponent(String(id))}`, { method: 'DELETE', }); } if (name === 'wp_post_find_replace') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); if (!Array.isArray(args?.replacements) || args.replacements.length === 0) { throw new Error('replacements debe ser un array no vacío'); } result = await callWyngesElementorEndpoint(`/post/${id}/find-replace`, { method: 'POST', body: JSON.stringify({ replacements: args.replacements, expect_count: args?.expect_count, dry_run: args?.dry_run === true, allow_block_count_change: args?.allow_block_count_change === true, allow_jsonld_change: args?.allow_jsonld_change === true, }), }); } if (name === 'wp_list_post_backups') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); result = await callWyngesElementorEndpoint(`/post/${id}/backups`, { method: 'GET', }); } if (name === 'wp_restore_post_backup') { const id = Number(args?.id); const backupKey = String(args?.backup_key || ''); if (!id) throw new Error('id requerido'); if (!backupKey) throw new Error('backup_key requerido'); result = await callWyngesElementorEndpoint(`/post/${id}/restore-backup`, { method: 'POST', body: JSON.stringify({ backup_key: backupKey, }), }); } if (name === 'wp_set_post_content') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); if (typeof args?.content !== 'string') throw new Error('content requerido (string)'); result = await callWyngesElementorEndpoint(`/post/${id}/set-content`, { method: 'POST', body: JSON.stringify({ content: args.content, mode: args?.mode, content_sha256: args?.content_sha256, make_backup: args?.make_backup, flush_cache: args?.flush_cache, dry_run: args?.dry_run === true, allow_unbalanced_blocks: args?.allow_unbalanced_blocks === true, }), }); } if (name === 'wp_set_post_terms') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); if (!args?.taxonomy || typeof args.taxonomy !== 'string') throw new Error('taxonomy requerido (string)'); if (!Array.isArray(args?.terms) || args.terms.length === 0) throw new Error('terms debe ser un array no vacío (nombres o IDs)'); result = await callWyngesElementorEndpoint(`/post/${id}/set-terms`, { method: 'POST', body: JSON.stringify({ taxonomy: args.taxonomy, terms: args.terms, append: args?.append === true, }), }); } if (name === 'wp_set_rankmath_meta') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); if (args?.robots === undefined && args?.canonical === undefined) { throw new Error('Enviá al menos robots o canonical'); } result = await callWyngesElementorEndpoint(`/post/${id}/rankmath-meta`, { method: 'POST', body: JSON.stringify({ robots: args?.robots, canonical: args?.canonical, }), }); } if (name === 'wp_insert_block') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); if (!args?.name || typeof args.name !== 'string') throw new Error('name requerido (string)'); if (!args?.html || typeof args.html !== 'string') throw new Error('html requerido (string)'); result = await callWyngesElementorEndpoint(`/post/${id}/insert-block`, { method: 'POST', body: JSON.stringify({ name: args.name, html: args.html, position: args?.position, after_marker: args?.after_marker, before_marker: args?.before_marker, wrap: args?.wrap, flush_cache: args?.flush_cache, dry_run: args?.dry_run === true, }), }); } if (name === 'wp_delete_block') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); if (!args?.name || typeof args.name !== 'string') throw new Error('name requerido (string)'); result = await callWyngesElementorEndpoint(`/post/${id}/delete-block`, { method: 'POST', body: JSON.stringify({ name: args.name, flush_cache: args?.flush_cache, dry_run: args?.dry_run === true, }), }); } if (name === 'wp_move_block') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); if (!args?.name || typeof args.name !== 'string') throw new Error('name requerido (string)'); result = await callWyngesElementorEndpoint(`/post/${id}/move-block`, { method: 'POST', body: JSON.stringify({ name: args.name, position: args?.position, after_marker: args?.after_marker, before_marker: args?.before_marker, flush_cache: args?.flush_cache, dry_run: args?.dry_run === true, }), }); } if (name === 'wp_replace_block') { const id = Number(args?.id); if (!id) throw new Error('id requerido'); if (!args?.name || typeof args.name !== 'string') throw new Error('name requerido (string)'); if (!args?.html || typeof args.html !== 'string') throw new Error('html requerido (string)'); result = await callWyngesElementorEndpoint(`/post/${id}/replace-block`, { method: 'POST', body: JSON.stringify({ name: args.name, html: args.html, wrap: args?.wrap, flush_cache: args?.flush_cache, dry_run: args?.dry_run === true, }), }); } if (!result) { throw new Error(`Tool Elementor no reconocida: ${name}`); } return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } async function WordPressProxy() { await setupFetchPolyfill(); logger.info('Starting WordPress MCP Proxy', 'PROXY'); try { await cleanupExpiredTokens(); } catch {} const sessionContext = createSessionContext(); const server = new Server( { name: 'WordPress MCP Remote Proxy', version: MCP_WORDPRESS_REMOTE_VERSION, }, { capabilities: { tools: {}, resources: {}, prompts: {}, logging: {}, completions: {}, }, } ); server.setRequestHandler(InitializeRequestSchema, async request => { try { const { initResult } = await detectTransportType(sessionContext, request.params); resolveInit(sessionContext, false); return { protocolVersion: initResult.protocolVersion || '2025-06-18', serverInfo: initResult.serverInfo || { name: 'WordPress MCP Remote Proxy', version: MCP_WORDPRESS_REMOTE_VERSION, }, capabilities: initResult.capabilities || { tools: {}, resources: {}, prompts: {}, logging: {}, completions: {}, }, instructions: 'MCP WordPress Remote Proxy', }; } catch (error: any) { // Fallback defensivo: si el detector del MCP original no logra conectar, // mantenemos vivo el proxy SSE y exponemos las tools custom Wynges. // Esto evita que mcp-proxy cierre el puerto 3010 durante el initialize. logger.warn('Transport detection failed; starting with custom WordPress tools only', 'PROXY', { error: error?.message || String(error), }); resolveInit(sessionContext, false); return { protocolVersion: '2025-06-18', serverInfo: { name: 'WordPress MCP Remote Proxy', version: MCP_WORDPRESS_REMOTE_VERSION, }, capabilities: { tools: {}, resources: {}, prompts: {}, logging: {}, completions: {}, }, instructions: 'MCP WordPress Remote Proxy', }; } }); const originalListToolsHandler = createWrappedHandler( HANDLER_CONFIGS.listTools, sessionContext ); const originalCallToolHandler = createWrappedHandler( HANDLER_CONFIGS.callTool, sessionContext ); server.setRequestHandler(ListToolsRequestSchema, async request => { let originalResult: any = { tools: [] }; try { originalResult = await originalListToolsHandler(request as any); } catch (error: any) { logger.warn('Original WordPress listTools failed; returning custom Wynges tools only', 'PROXY', { error: error?.message || String(error), }); } const customToolNames = ELEMENTOR_TOOL_NAMES; const originalTools = Array.isArray(originalResult?.tools) ? originalResult.tools.filter((tool: any) => !customToolNames.has(tool?.name)) : []; return { ...originalResult, tools: [ ...originalTools, ...ELEMENTOR_TOOLS, ], }; }); server.setRequestHandler(CallToolRequestSchema, async request => { const toolName = (request as any)?.params?.name; const args = (request as any)?.params?.arguments || {}; if (ELEMENTOR_TOOL_NAMES.has(toolName)) { try { return await handleElementorTool(toolName, args); } catch (error: any) { return { isError: true, content: [ { type: 'text', text: error?.message || String(error), }, ], }; } } return originalCallToolHandler(request as any); }); server.setRequestHandler( ListResourcesRequestSchema, createWrappedHandler(HANDLER_CONFIGS.listResources, sessionContext) ); server.setRequestHandler( ListResourceTemplatesRequestSchema, createWrappedHandler(HANDLER_CONFIGS.listResourceTemplates, sessionContext) ); server.setRequestHandler( ReadResourceRequestSchema, createWrappedHandler(HANDLER_CONFIGS.readResource, sessionContext) ); server.setRequestHandler( SubscribeRequestSchema, createWrappedHandler(HANDLER_CONFIGS.subscribe, sessionContext) ); server.setRequestHandler( UnsubscribeRequestSchema, createWrappedHandler(HANDLER_CONFIGS.unsubscribe, sessionContext) ); server.setRequestHandler( ListPromptsRequestSchema, createWrappedHandler(HANDLER_CONFIGS.listPrompts, sessionContext) ); server.setRequestHandler( GetPromptRequestSchema, createWrappedHandler(HANDLER_CONFIGS.getPrompt, sessionContext) ); server.setRequestHandler( SetLevelRequestSchema, createWrappedHandler(HANDLER_CONFIGS.setLevel, sessionContext) ); server.setRequestHandler( CompleteRequestSchema, createWrappedHandler(HANDLER_CONFIGS.complete, sessionContext) ); server.setRequestHandler( ListRootsRequestSchema, createWrappedHandler(HANDLER_CONFIGS.listRoots, sessionContext) ); const transport = new StdioServerTransport(); server .connect(transport) .then(() => { logger.info('MCP server connected', 'PROXY'); }) .catch(error => { logger.error('Error starting MCP server', 'PROXY', error); process.exit(1); }); } WordPressProxy();