orquestra-mcp 1.18.2 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/platformPlaybooks.mjs +137 -0
- package/src/server.mjs +286 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# orquestra-mcp
|
|
2
2
|
|
|
3
|
-
Servidor MCP (Model Context Protocol) para Orquestra. Expone **
|
|
3
|
+
Servidor MCP (Model Context Protocol) para Orquestra. Expone **99 tools**
|
|
4
4
|
que llaman a la API de `orquestra-infra`: lectura de contexto/dashboard/
|
|
5
5
|
workspace/infra/paridad/listados, escritura de tareas/ideas/changelog/
|
|
6
6
|
notas/docs/módulos/flujo/categorías/infra/proyectos, y corridas de QA —
|
|
@@ -192,7 +192,7 @@ create/update sigue siendo criterio del agente y del humano.
|
|
|
192
192
|
|
|
193
193
|
## Estado
|
|
194
194
|
|
|
195
|
-
|
|
195
|
+
99 tools: lectura (contexto, dashboard, workspace, infra, paridad, standup,
|
|
196
196
|
sync report, setup guide, playbooks de etapa y de plataforma, catálogo de
|
|
197
197
|
skills, search) + `list_*` de cada colección + creación/edición de tareas
|
|
198
198
|
(con checklist, tiempo, adjuntos, merge), ideas (con comentarios), notas,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orquestra-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"description": "Servidor MCP de Orquestra -- 89 tools para leer y escribir tareas, ideas, changelog, infraestructura, contenido, QA y más desde un agente de IA",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
// Puerto exacto de orquestra-web/src/lib/platformPlaybooks.js (PLATFORM_PLAYBOOKS) --
|
|
2
2
|
// constantes estaticas, sin acceso a red ni a la base de datos. get_platform_playbook
|
|
3
3
|
// lo lee directo desde aca (mismo criterio que stages.mjs/foundations.mjs).
|
|
4
|
+
// Puerto de TRACKING_TOOLS de la web: herramientas de medición conectables a una
|
|
5
|
+
// distribución web. El ID vive en distributions.publishForm[key]. `pattern` valida
|
|
6
|
+
// el formato (se serializa como string en la web, acá es el RegExp directo).
|
|
7
|
+
export const TRACKING_TOOLS = [
|
|
8
|
+
{ key: 'ga4_measurement_id', label: 'Google Analytics 4', idLabel: 'ID de medición', pattern: /^G-[A-Z0-9]{6,12}$/, example: 'G-ABC123DEF4', where: 'Google Analytics → Admin → Flujos de datos' },
|
|
9
|
+
{ key: 'meta_pixel_id', label: 'Meta Pixel', idLabel: 'ID del Pixel', pattern: /^\d{8,20}$/, example: '1234567890123456', where: 'Meta Business → Events Manager' },
|
|
10
|
+
{ key: 'gtm_container_id', label: 'Google Tag Manager', idLabel: 'ID del contenedor', pattern: /^GTM-[A-Z0-9]{4,10}$/, example: 'GTM-ABC1234', where: 'Tag Manager → Administrador → ID del contenedor' },
|
|
11
|
+
]
|
|
12
|
+
|
|
4
13
|
export const PLATFORM_PLAYBOOKS = {
|
|
5
14
|
|
|
6
15
|
ios: {
|
|
@@ -265,4 +274,132 @@ export const PLATFORM_PLAYBOOKS = {
|
|
|
265
274
|
},
|
|
266
275
|
],
|
|
267
276
|
},
|
|
277
|
+
web: {
|
|
278
|
+
label: 'Web — SEO y analítica',
|
|
279
|
+
color: '#4A7CFF',
|
|
280
|
+
// Sin formulario de tienda ni "Completar con IA": una web no declara
|
|
281
|
+
// nada en una consola, solo tiene el checklist y la guía de referencia
|
|
282
|
+
// (DistributionGuide.jsx esconde la columna del formulario si es false).
|
|
283
|
+
hasForm: false,
|
|
284
|
+
consoleName: 'SEO y analítica',
|
|
285
|
+
moduleName: 'SEO y analítica web',
|
|
286
|
+
moduleDescription: 'Checklist para que la web/landing nazca medible e indexable: Search Console, analítica, rendimiento y SEO básico, para que no se olvide nada al lanzar.',
|
|
287
|
+
moduleTags: ['playbook', 'seo', 'analitica'],
|
|
288
|
+
|
|
289
|
+
referenceGuide: [
|
|
290
|
+
{
|
|
291
|
+
key: 'search_console',
|
|
292
|
+
title: 'Google Search Console',
|
|
293
|
+
body: 'Sin esto Google no te dice si te indexó ni por qué no apareces. Agrega la propiedad (mejor tipo "Dominio", verificando por registro DNS TXT, así cubre www, http/https y subdominios), envía el sitemap.xml y revisa el informe de Páginas indexadas a la semana. Es gratis y es la fuente real de errores de indexación y consultas de búsqueda.',
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
key: 'analytics',
|
|
297
|
+
title: 'Google Analytics 4 / Firebase Analytics',
|
|
298
|
+
body: 'Mide visitas, origen y conversiones. Si el sitio ya usa Firebase, activa Google Analytics en el proyecto y usa el SDK web (measurementId); si no, crea una propiedad GA4 y pega la etiqueta. Define al menos un evento de conversión (registro, clic en CTA, descarga). Si el sitio sirve a usuarios de la UE/México, acuerda el aviso de cookies y consentimiento antes de activar.',
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
key: 'meta_pixel',
|
|
302
|
+
title: 'Meta Pixel (si harás anuncios)',
|
|
303
|
+
body: 'Solo si vas a pautar en Facebook/Instagram: crea el Pixel en Meta Business → Events Manager y pega su ID numérico abajo en "Conexiones de medición". Sirve para medir conversiones de los anuncios y armar audiencias de remarketing. Requiere consentimiento de cookies en la UE/México y debe mencionarse en la política de privacidad.',
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
key: 'bing_webmaster',
|
|
307
|
+
title: 'Bing Webmaster Tools (opcional)',
|
|
308
|
+
body: 'Importa la propiedad directo desde Search Console en un clic. Cubre Bing, DuckDuckGo y parte de los asistentes con IA que usan su índice.',
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
key: 'sitemap_robots',
|
|
312
|
+
title: 'sitemap.xml y robots.txt',
|
|
313
|
+
body: 'Ambos en la raíz del dominio y respondiendo 200. robots.txt debe apuntar al sitemap y NO bloquear todo (Disallow: /) por un resto del entorno de staging. En SPAs (React/Vite) verifica que el sitemap lo genere el build, no que sea un archivo olvidado a mano.',
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
key: 'meta_tags',
|
|
317
|
+
title: 'Título, meta description y etiquetas por página',
|
|
318
|
+
body: 'Cada página indexable necesita <title> único (≤60 caracteres) y meta description (≤155) que venda el clic, un solo <h1>, y <link rel="canonical">. Una SPA que sirve el mismo <title> en todas las rutas pierde casi todo el SEO: usa prerender/SSR o un manejador de head por ruta.',
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
key: 'social_preview',
|
|
322
|
+
title: 'Open Graph / Twitter Cards',
|
|
323
|
+
body: 'og:title, og:description y og:image (1200x630) para que al compartir el enlace en WhatsApp, LinkedIn o X se vea una tarjeta, no una URL pelona. Pruébalo pegando el enlace en cada red antes de lanzar.',
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
key: 'performance',
|
|
327
|
+
title: 'Rendimiento: Core Web Vitals / PageSpeed',
|
|
328
|
+
body: 'Mide con PageSpeed Insights (móvil primero). Los puntos que casi siempre pesan: imágenes sin comprimir ni dimensiones (usa WebP/AVIF y lazy-loading), fuentes bloqueantes, JS sin dividir. LCP < 2.5s, CLS < 0.1, INP < 200ms son los umbrales de "bueno" de Google.',
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
key: 'structured_data',
|
|
332
|
+
title: 'Datos estructurados (schema.org)',
|
|
333
|
+
body: 'JSON-LD de Organization (y SoftwareApplication, FAQPage o Product según el sitio) para optar a resultados enriquecidos. Valida con la prueba de resultados enriquecidos de Google antes de publicar.',
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
key: 'https_domain',
|
|
337
|
+
title: 'HTTPS, dominio canónico y redirecciones',
|
|
338
|
+
body: 'Elige una sola versión (con o sin www) y redirige 301 las demás, incluido http → https. Evita cadenas de redirecciones y páginas duplicadas bajo dos URLs.',
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
key: 'privacy_legal',
|
|
342
|
+
title: 'Privacidad y consentimiento',
|
|
343
|
+
body: 'Si usas analítica o cookies, la política de privacidad debe mencionarlas y estar enlazada en el pie. Es requisito de Google Analytics aceptar sus términos de procesamiento de datos.',
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
|
|
347
|
+
tasks: [
|
|
348
|
+
{
|
|
349
|
+
title: 'Agregar el sitio a Google Search Console y enviar el sitemap',
|
|
350
|
+
description: 'Crear la propiedad (tipo Dominio, verificación por DNS TXT), enviar sitemap.xml y confirmar que no marque errores de indexación. Sin esto no hay forma de saber si Google ve el sitio.',
|
|
351
|
+
type: 'tech', priority: 'high', tags: ['seo', 'web', 'search-console'], offsetDays: 1,
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
title: 'Instalar analítica (Google Analytics 4 / Firebase Analytics) y definir una conversión',
|
|
355
|
+
description: 'Activar GA4 (o Google Analytics en el proyecto Firebase y usar el SDK web con measurementId), confirmar en Tiempo real que registra visitas propias y marcar al menos un evento como conversión (registro, CTA, descarga).',
|
|
356
|
+
type: 'tech', priority: 'high', tags: ['seo', 'web', 'analytics'], offsetDays: 2, checkKey: 'analytics',
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
title: 'Instalar Meta Pixel y verificar el evento PageView',
|
|
360
|
+
description: 'Solo si habrá anuncios en Facebook/Instagram: crear el Pixel en Events Manager, instalarlo en el sitio y confirmar PageView con la extensión Meta Pixel Helper o la pestaña Probar eventos.',
|
|
361
|
+
type: 'tech', priority: 'low', tags: ['seo', 'web', 'analytics', 'meta'], offsetDays: 4, checkKey: 'meta_pixel',
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
title: 'Verificar que sitemap.xml y robots.txt respondan 200 y no bloqueen el sitio',
|
|
365
|
+
description: 'Abrirlos en el navegador en el dominio de producción. Revisar que robots.txt no tenga "Disallow: /" heredado de staging y que apunte al sitemap. En SPAs, comprobar que el hosting no devuelva index.html para estas rutas.',
|
|
366
|
+
type: 'tech', priority: 'high', tags: ['seo', 'web'], offsetDays: 2,
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
title: 'Revisar título, meta description, h1 y canonical de cada página indexable',
|
|
370
|
+
description: 'Un <title> único (≤60) y meta description (≤155) por página, un solo h1 y canonical. Si es SPA, confirmar que cada ruta los entrega al rastreador (prerender/SSR o manejador de head).',
|
|
371
|
+
type: 'tech', priority: 'medium', tags: ['seo', 'web'], offsetDays: 3,
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
title: 'Configurar Open Graph / Twitter Cards con imagen 1200x630',
|
|
375
|
+
description: 'og:title, og:description, og:image y twitter:card. Probar el enlace pegándolo en WhatsApp, LinkedIn y X.',
|
|
376
|
+
type: 'business', priority: 'medium', tags: ['seo', 'web', 'social'], offsetDays: 3,
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
title: 'Medir y corregir Core Web Vitals en PageSpeed Insights (móvil)',
|
|
380
|
+
description: 'Correr PageSpeed Insights sobre la home y las páginas clave. Objetivo: LCP < 2.5s, CLS < 0.1, INP < 200ms. Atacar primero imágenes sin comprimir, fuentes bloqueantes y JS sin dividir.',
|
|
381
|
+
type: 'tech', priority: 'medium', tags: ['seo', 'web', 'performance'], offsetDays: 5,
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
title: 'Fijar dominio canónico y redirecciones 301 (www / http → https)',
|
|
385
|
+
description: 'Elegir una sola versión del dominio y redirigir las demás con 301, sin cadenas de redirecciones. Confirmar HTTPS válido.',
|
|
386
|
+
type: 'tech', priority: 'medium', tags: ['seo', 'web'], offsetDays: 3,
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
title: 'Agregar datos estructurados (JSON-LD) y validarlos',
|
|
390
|
+
description: 'Organization como mínimo, más SoftwareApplication/FAQPage/Product según el sitio. Validar con la prueba de resultados enriquecidos de Google.',
|
|
391
|
+
type: 'tech', priority: 'low', tags: ['seo', 'web'], offsetDays: 7,
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
title: 'Importar la propiedad a Bing Webmaster Tools',
|
|
395
|
+
description: 'Importar desde Search Console en un clic. Opcional, pero cubre Bing/DuckDuckGo y asistentes de IA que usan su índice.',
|
|
396
|
+
type: 'business', priority: 'low', tags: ['seo', 'web'], offsetDays: 7,
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
title: 'Verificar/crear política de privacidad que mencione la analítica y cookies',
|
|
400
|
+
description: 'Si el sitio usa analítica o cookies, la política de privacidad tiene que mencionarlo y estar enlazada en el pie. Revisar también si hace falta aviso de consentimiento de cookies.',
|
|
401
|
+
type: 'business', priority: 'medium', tags: ['seo', 'web', 'compliance'], offsetDays: 3, checkKey: 'privacy',
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
},
|
|
268
405
|
}
|
package/src/server.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { z } from 'zod'
|
|
|
6
6
|
import { loadConfig, apiGet, apiPost, apiPatch, apiDelete, putToPresignedUrl, ApiError } from './api.mjs'
|
|
7
7
|
import { STAGES, PLAYBOOKS } from './stages.mjs'
|
|
8
8
|
import { FOUNDATIONS } from './foundations.mjs'
|
|
9
|
-
import { PLATFORM_PLAYBOOKS } from './platformPlaybooks.mjs'
|
|
9
|
+
import { PLATFORM_PLAYBOOKS, TRACKING_TOOLS } from './platformPlaybooks.mjs'
|
|
10
10
|
import { SKILLS_CATALOG } from './skillsCatalog.mjs'
|
|
11
11
|
import { parseSource, previewSkill, installSkill } from './skillInstall.mjs'
|
|
12
12
|
|
|
@@ -89,6 +89,8 @@ Al aplicar un playbook con apply_stage_playbook -- sobre todo el de etapa "post"
|
|
|
89
89
|
|
|
90
90
|
Al aplicar apply_platform_playbook (Platform Playbook de publicación iOS/Android) con publishForm: solo completa las claves para las que tengas base real del proyecto (código, get_project_context, servicios/entornos ya configurados) -- mismo criterio que el botón "Completar con IA" de la web: category/content_rights/data_collection/content_rating suelen inferirse bien para una app B2B sin ads/tracking, pero pricing, availability, package/bundle ID, cuentas demo para el reviewer o el nombre exacto de la app NUNCA se inventan -- dejalas fuera de publishForm para que el usuario las complete a mano. Si la tool devuelve publishFormConflict, la distribución ya tenía datos guardados en esas claves y no se escribió nada -- mostrale al usuario el valor actual vs. el propuesto de cada una, y repetí el llamado con confirmReplace:true solo si confirma explícito el reemplazo (no lo asumas ni lo repitas sin esa confirmación).
|
|
91
91
|
|
|
92
|
+
Analítica y anuncios en un sitio web: antes de instalar o tocar Google Analytics 4, Meta Pixel o Tag Manager en el código, llama a get_tracking_ids -- si la distribución web ya tiene el ID guardado (ga4_measurement_id, meta_pixel_id, gtm_container_id), úsalo tal cual; nunca inventes un ID ni se lo vuelvas a pedir al usuario. Si falta y el usuario te lo da (o lo lees literal en el código/consola real), guárdalo con set_tracking_ids para que el resto de agentes y la guía web lo vean. Si ya hay uno distinto guardado, no lo reemplaces sin confirmación explícita (confirmReplace).
|
|
93
|
+
|
|
92
94
|
Durante el trabajo normal de código, sin que el usuario te lo pida explícitamente y SIN ESPERAR a que lo mencione (si tienes esta tool disponible en la sesión, se usa por default en todo trabajo real, no solo cuando alguien dice "usa Orquestra"):
|
|
93
95
|
- Si te pide una funcionalidad o arreglo real (no una pregunta rápida ni exploración de código sin cambios), revisa con list_tasks/search si ya existe una tarea relacionada; si no, créala con create_task ANTES de empezar a trabajar, en el estatus inicial del proyecto (ver list_statuses). Si no hay un proyecto default configurado para esta carpeta, no te rindas ahí -- usa get_workspace_summary para encontrar el proyecto correcto y pásalo explícito como projectId en cada llamada.
|
|
94
96
|
- Mientras trabajas, mueve la tarea por los estatus reales según avanzas (no la dejes en el inicial hasta el final). Al terminar, muévela a su estatus final con update_task y registra una entrada de changelog con create_changelog_entry -- así el usuario nunca tiene que documentar esto a mano. Si sabes en qué repo real estás trabajando (podés correr "git remote get-url origin" o ya lo sabes por la carpeta), pásalo SIEMPRE como "repo" ("owner/name") -- encontrado real (17 ago 2026): ni el hook local ni el GitHub Action ni la reconciliación de orquestra-infra lo estaban mandando pese a que el campo existe hace rato, así que ninguna entrada automática sabía de qué repo/plataforma venía. Si además la distribución de ese repo ya está identificada (ver list_distributions), pásala en "distributions" -- sin esto el changelog no puede filtrar/agrupar por plataforma.
|
|
@@ -369,7 +371,7 @@ export function createServer() {
|
|
|
369
371
|
'get_platform_playbook',
|
|
370
372
|
{
|
|
371
373
|
description: 'Guía de referencia + checklist accionable para publicar en App Store Connect (iOS) o Google Play Console (Android) -- "Platform Playbook", paralelo a get_stage_playbook pero sobre una PLATAFORMA/distribución concreta, no la etapa del proyecto. No llama a la API -- datos estáticos. "referenceGuide" es texto de referencia (no genera tareas); "tasks" es el checklist accionable (misma forma que get_stage_playbook, se puede convertir en módulo + tareas reales).',
|
|
372
|
-
inputSchema: { platform: z.enum(['ios', 'android']).describe('ios (App Store Connect) | android (Google Play Console)') },
|
|
374
|
+
inputSchema: { platform: z.enum(['ios', 'android', 'web']).describe('ios (App Store Connect) | android (Google Play Console) | web (SEO y analítica: Search Console, GA4/Firebase Analytics, sitemap, meta tags, Core Web Vitals)') },
|
|
373
375
|
},
|
|
374
376
|
async ({ platform }) => textResult(PLATFORM_PLAYBOOKS[platform])
|
|
375
377
|
)
|
|
@@ -508,7 +510,7 @@ export function createServer() {
|
|
|
508
510
|
categoryId: z.string().optional(),
|
|
509
511
|
moduleId: z.string().describe('OBLIGATORIO -- módulo al que pertenece esta tarea, para poder agruparla/revisarla después. Si no existe uno relacionado, llamá primero a list_modules (para no duplicar) y create_module.'),
|
|
510
512
|
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').describe('OBLIGATORIO -- fecha estimada de término (YYYY-MM-DD), calculada con criterio real según prioridad/tamaño del trabajo (mismo criterio que la "revisión de sincronización" de get_sync_report), nunca un valor arbitrario.'),
|
|
511
|
-
reviewDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').optional().describe('Fecha para volver a MIRAR esta tarea -- recordatorio, NO un entregable. Úsalo para tareas de espera sin deadline real (ej. "esperar respuesta de Apple y revisar en 5 días"): en ese caso NO llenes dueDate, llena reviewDate. Distinto de dueDate (fecha de término real de un entregable) -- una tarea puede tener ambos, uno solo, o ninguno. Dispara un aviso (notificación + correo) al assignedTo el día que llega, un solo disparo.'),
|
|
513
|
+
reviewDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato esperado: YYYY-MM-DD').optional().describe('Fecha para volver a MIRAR esta tarea -- recordatorio, NO un entregable. Para un aviso CON HORA (ej. en 5 horas) usa set_task_reminder. Úsalo para tareas de espera sin deadline real (ej. "esperar respuesta de Apple y revisar en 5 días"): en ese caso NO llenes dueDate, llena reviewDate. Distinto de dueDate (fecha de término real de un entregable) -- una tarea puede tener ambos, uno solo, o ninguno. Dispara un aviso (notificación + correo) al assignedTo el día que llega, un solo disparo.'),
|
|
512
514
|
assignedTo: z.string().optional().describe('uid del miembro asignado'),
|
|
513
515
|
tags: z.array(z.string()).optional(),
|
|
514
516
|
scope: z.array(z.object({ distId: z.string(), layer: z.string().optional(), done: z.boolean() })).optional()
|
|
@@ -613,6 +615,190 @@ export function createServer() {
|
|
|
613
615
|
}
|
|
614
616
|
)
|
|
615
617
|
|
|
618
|
+
// Revisión de Contenido (ORQ-184, 2 oct 2026). Aprobar NO tiene tool propia:
|
|
619
|
+
// es update_content con status "approved" (el server estampa quién/cuándo).
|
|
620
|
+
// GitHub (ORQ-200, 3 oct 2026): las rutas aceptan token de servicio del proyecto.
|
|
621
|
+
// Crear/borrar integraciones NO se expone a propósito: maneja el PAT y exige
|
|
622
|
+
// sesión de miembro (web).
|
|
623
|
+
registerRead(
|
|
624
|
+
'list_github_integrations',
|
|
625
|
+
'/v1/github-integrations',
|
|
626
|
+
'Lista los repos de GitHub conectados a un proyecto (repo, rama, distribución vinculada, último error/chequeo). Úsala para saber qué repos alimentan el changelog antes de pedir actividad o un sync.',
|
|
627
|
+
{},
|
|
628
|
+
(args, projectId) => ({ projectId })
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
registerRead(
|
|
632
|
+
'get_github_activity',
|
|
633
|
+
'/v1/github-activity',
|
|
634
|
+
'Actividad reciente de GitHub por repo conectado del proyecto (commits, PRs). Sirve para saber qué se hizo realmente sin leer el repo a mano.',
|
|
635
|
+
{ days: z.number().int().min(1).max(90).optional().describe('Ventana en días hacia atrás (default del servidor)') },
|
|
636
|
+
(args, projectId) => ({ projectId, days: args.days })
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
server.registerTool(
|
|
640
|
+
'sync_task_from_github',
|
|
641
|
+
{
|
|
642
|
+
description: 'Actualiza UNA tarea desde GitHub: busca commits/PRs que la mencionan (ORQ-###) y refleja su estado real. Si el proyecto tiene varios repos conectados, pasa integrationId (list_github_integrations).',
|
|
643
|
+
inputSchema: {
|
|
644
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
645
|
+
taskId: z.string().describe('ID de la tarea'),
|
|
646
|
+
integrationId: z.string().optional().describe('Integración (repo) concreta; opcional si solo hay una'),
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
async ({ projectId, taskId, integrationId }) => {
|
|
650
|
+
try {
|
|
651
|
+
return textResult(await apiPost(config, `/v1/tasks/${taskId}/sync-github`, { projectId: resolveProjectId(projectId), ...(integrationId ? { integrationId } : {}) }))
|
|
652
|
+
} catch (err) {
|
|
653
|
+
return errorResult(err)
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
server.registerTool(
|
|
659
|
+
'run_closure_scan',
|
|
660
|
+
{
|
|
661
|
+
description: 'Escanea la actividad reciente de GitHub y PROPONE cerrar tareas cuyo trabajo ya aparece hecho (sugerencias closure_proposal, nunca cierra nada sola). Revisa las propuestas con list_suggestions y acepta con accept_suggestion.',
|
|
662
|
+
inputSchema: {
|
|
663
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
664
|
+
days: z.number().int().min(1).max(90).optional().describe('Ventana en días hacia atrás (default del servidor)'),
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
async ({ projectId, days }) => {
|
|
668
|
+
try {
|
|
669
|
+
return textResult(await apiPost(config, '/v1/closure-scan', { projectId: resolveProjectId(projectId), ...(days ? { days } : {}) }))
|
|
670
|
+
} catch (err) {
|
|
671
|
+
return errorResult(err)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
server.registerTool(
|
|
677
|
+
'request_content_changes',
|
|
678
|
+
{
|
|
679
|
+
description: 'Pide cambios en una pieza de contenido que está en revisión: guarda tu comentario en el historial (reviewComments), la devuelve a borrador y avisa (push + correo) a quien la creó. No existe un estado "rechazado". En una pieza multi-canal (channels[]) channelId es obligatorio. Para aprobar usa update_content con status "approved".',
|
|
680
|
+
inputSchema: {
|
|
681
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
682
|
+
contentId: z.string().describe('ID de la pieza (list_content)'),
|
|
683
|
+
text: z.string().describe('Qué hay que cambiar -- queda guardado como comentario de revisión'),
|
|
684
|
+
channelId: z.string().optional().describe('Solo si la pieza es multi-canal: el canal (variante) al que se refieren los cambios'),
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
async ({ projectId, contentId, text, channelId }) => {
|
|
688
|
+
try {
|
|
689
|
+
const data = await apiPost(config, `/v1/content/${contentId}/request-changes`, { projectId: resolveProjectId(projectId), text, ...(channelId ? { channelId } : {}) })
|
|
690
|
+
return textResult(data)
|
|
691
|
+
} catch (err) {
|
|
692
|
+
return errorResult(err)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
server.registerTool(
|
|
698
|
+
'ai_review_content',
|
|
699
|
+
{
|
|
700
|
+
description: 'Pre-revisión visual con IA de la imagen de una pieza contra el Brand Kit (colores, texto legible, voz de marca): deja un semáforo green/yellow/red con observaciones en aiPreReview. Es informativa, nunca aprueba ni rechaza sola. Cuesta 1 crédito de IA. Se corre sola antes del aviso al aprobador.',
|
|
701
|
+
inputSchema: {
|
|
702
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
703
|
+
contentId: z.string().describe('ID de la pieza (list_content)'),
|
|
704
|
+
channelId: z.string().optional().describe('Solo si la pieza es multi-canal'),
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
async ({ projectId, contentId, channelId }) => {
|
|
708
|
+
try {
|
|
709
|
+
return textResult(await apiPost(config, `/v1/content/${contentId}/ai-review`, { projectId: resolveProjectId(projectId), ...(channelId ? { channelId } : {}) }))
|
|
710
|
+
} catch (err) {
|
|
711
|
+
return errorResult(err)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
server.registerTool(
|
|
717
|
+
'regenerate_content_image',
|
|
718
|
+
{
|
|
719
|
+
description: 'Regenera la imagen de una pieza con IA usando su imagePrompt original MÁS tus comentarios (y los anteriores). Corre en segundo plano (la respuesta es inmediata, "regenerating"; el resultado aparece en la pieza en ~1 minuto: la imagen nueva queda en mediaFiles, la anterior en imageHistory, y la pieza vuelve a "review" avisando al aprobador). Gasta 5 créditos de IA del workspace (se reembolsan si falla) y hay un tope de 5 regeneraciones por pieza/canal. mode "reference" (default) edita la imagen actual para mantener la composición; "scratch" genera desde cero.',
|
|
720
|
+
inputSchema: {
|
|
721
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
722
|
+
contentId: z.string().describe('ID de la pieza (list_content)'),
|
|
723
|
+
text: z.string().describe('Comentarios para la nueva imagen (ej. "más luz, sin texto")'),
|
|
724
|
+
mode: z.enum(['reference', 'scratch']).optional().describe('reference (default) = editar la imagen actual; scratch = desde cero'),
|
|
725
|
+
channelId: z.string().optional().describe('Solo si la pieza es multi-canal: el canal (variante) cuya imagen se regenera'),
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
async ({ projectId, contentId, text, mode, channelId }) => {
|
|
729
|
+
try {
|
|
730
|
+
const data = await apiPost(config, `/v1/content/${contentId}/regenerate-image`, { projectId: resolveProjectId(projectId), text, ...(mode ? { mode } : {}), ...(channelId ? { channelId } : {}) })
|
|
731
|
+
return textResult(data)
|
|
732
|
+
} catch (err) {
|
|
733
|
+
return errorResult(err)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
// Recordatorio con hora de una tarea (2 oct 2026, a pedido de José) --
|
|
739
|
+
// "recuérdame en 5 horas revisar X con Ana" / "mañana revisa Y para poder
|
|
740
|
+
// cerrarla". Azúcar sobre PATCH /v1/tasks { remindAt (epoch ms), remindNote }:
|
|
741
|
+
// el job orquestra-reminders (modo remindAt, cada ~15 min) avisa por
|
|
742
|
+
// notificación + correo a assignedTo (o createdBy) y deja remindAt en null.
|
|
743
|
+
// El cálculo de "en 5h"/"mañana" se hace ACÁ (hora local de la máquina del
|
|
744
|
+
// usuario), no en el server, que no conoce su zona horaria.
|
|
745
|
+
const REMIND_IN_RE = /^(\d+)\s*(m|min|h|hr|d)$/i
|
|
746
|
+
function resolveRemindAt({ remindIn, remindAt }) {
|
|
747
|
+
if (remindIn && remindAt) throw new Error('Manda remindIn O remindAt, no los dos.')
|
|
748
|
+
if (remindAt) {
|
|
749
|
+
const ms = Date.parse(remindAt)
|
|
750
|
+
if (Number.isNaN(ms)) throw new Error('remindAt inválido -- formato ISO 8601, idealmente con zona (ej. 2026-10-03T09:00:00-06:00).')
|
|
751
|
+
return ms
|
|
752
|
+
}
|
|
753
|
+
if (!remindIn) throw new Error('Falta remindIn (ej. "5h", "30m", "2d", "tomorrow") o remindAt (ISO 8601).')
|
|
754
|
+
const v = remindIn.trim().toLowerCase()
|
|
755
|
+
if (v === 'tomorrow' || v === 'mañana') {
|
|
756
|
+
const d = new Date()
|
|
757
|
+
d.setDate(d.getDate() + 1)
|
|
758
|
+
d.setHours(9, 0, 0, 0)
|
|
759
|
+
return d.getTime()
|
|
760
|
+
}
|
|
761
|
+
const m = REMIND_IN_RE.exec(v)
|
|
762
|
+
if (!m) throw new Error('remindIn inválido -- usa "30m", "5h", "2d" o "tomorrow" (mañana 9:00 hora local).')
|
|
763
|
+
const n = Number(m[1])
|
|
764
|
+
const unit = m[2][0] // m | h | d
|
|
765
|
+
const ms = unit === 'm' ? n * 60000 : unit === 'h' ? n * 3600000 : n * 86400000
|
|
766
|
+
return Date.now() + ms
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
server.registerTool(
|
|
770
|
+
'set_task_reminder',
|
|
771
|
+
{
|
|
772
|
+
description: 'Programa (o quita) un recordatorio CON HORA sobre una tarea: a esa hora Orquestra avisa por notificación in-app + correo a la persona asignada (o a quien creó la tarea si no hay asignado), una sola vez. Úsalo para "recuérdame mañana revisar X para poder cerrarla" o "en 5 horas revisarla con Ana": pasa la nota con QUÉ hay que revisar/con quién. Resolución real ~15 min (no es un reloj al minuto). Para un recordatorio por DÍA sin hora sigue sirviendo reviewDate en update_task. Mandar clear=true quita el recordatorio pendiente.',
|
|
773
|
+
inputSchema: {
|
|
774
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
775
|
+
taskId: z.string().describe('Tarea sobre la que se programa el recordatorio'),
|
|
776
|
+
remindIn: z.string().optional().describe('Relativo a ahora: "30m", "5h", "2d" o "tomorrow" (mañana 9:00, hora local). Alternativa a remindAt.'),
|
|
777
|
+
remindAt: z.string().optional().describe('Momento exacto en ISO 8601, idealmente con zona horaria (ej. 2026-10-03T16:30:00-06:00). Alternativa a remindIn.'),
|
|
778
|
+
note: z.string().max(500).optional().describe('Qué hay que revisar o con quién (aparece en la notificación y el correo). Muy recomendable.'),
|
|
779
|
+
clear: z.boolean().optional().describe('true = quita el recordatorio pendiente de la tarea (ignora remindIn/remindAt).'),
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
async ({ projectId, taskId, remindIn, remindAt, note, clear }) => {
|
|
783
|
+
try {
|
|
784
|
+
const body = { projectId: resolveProjectId(projectId), id: taskId }
|
|
785
|
+
if (clear) {
|
|
786
|
+
body.remindAt = null
|
|
787
|
+
} else {
|
|
788
|
+
body.remindAt = resolveRemindAt({ remindIn, remindAt })
|
|
789
|
+
if (note !== undefined) body.remindNote = note
|
|
790
|
+
}
|
|
791
|
+
const data = await apiPatch(config, '/v1/tasks', body)
|
|
792
|
+
return textResult({
|
|
793
|
+
...data,
|
|
794
|
+
reminder: clear ? 'quitado' : { remindAt: new Date(body.remindAt).toISOString(), note: note ?? null },
|
|
795
|
+
})
|
|
796
|
+
} catch (err) {
|
|
797
|
+
return errorResult(err)
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
)
|
|
801
|
+
|
|
616
802
|
// Postproxy (integración de Contenido, 24 ago 2026) -- server.registerTool
|
|
617
803
|
// directo, mismo criterio que merge_task/unmerge_task: son POST a
|
|
618
804
|
// `${path}/${id}/connect-postproxy|publish-postproxy`, no un CRUD
|
|
@@ -622,7 +808,7 @@ export function createServer() {
|
|
|
622
808
|
server.registerTool(
|
|
623
809
|
'connect_postproxy_channel',
|
|
624
810
|
{
|
|
625
|
-
description: 'Inicia la conexión de un canal (facebook/instagram/tiktok/linkedin) a Postproxy. Devuelve UNA de dos formas: {url} -- OAuth hosteado por Postproxy, el usuario tiene que abrirlo y completarlo a mano (el agente no puede terminar un OAuth por su cuenta), pásale esa URL tal cual; o {needsPageSelection, profileId, profileLabel, pages} -- pasa con facebook y linkedin, cuando ya hay un login de esa red conectado en la cuenta (un solo login da acceso a varias Pages de Facebook, o al perfil personal y las Organization Pages de LinkedIn, no hace falta repetir el OAuth) y hace falta elegir cuál corresponde a este canal. Esa elección hoy es un flujo de la web (Cuentas conectadas) -- si te llega `needsPageSelection`, dile al usuario que complete la selección ahí, esta tool no tiene una contraparte para elegir la página. Modelo real de Postproxy: cada perfil (login) vive en UN solo profile group y Orquestra usa un group por proyecto -- el OAuth de esta tool deja el perfil nuevo en el group de este proyecto; una cuenta de Instagram/TikTok conectada desde otro proyecto NO se puede reusar acá (sería publicar en la cuenta de otro proyecto), hay que conectar la cuenta correcta. Solo para los tipos que soporta Postproxy -- un canal newsletter (Buttondown) se conecta desde la web, no con esta tool.',
|
|
811
|
+
description: 'Inicia la conexión de un canal (facebook/instagram/tiktok/linkedin) a Postproxy. DISPONIBLE BAJO SOLICITUD (1 oct 2026): si el workspace no tiene Postproxy habilitado, la API responde 403 `postproxy_not_enabled` -- no reintentes ni busques otra vía; dile al usuario que un admin la solicite desde Contenido → Cuentas conectadas → "Solicitar conexión" en la web (se habilita a mano, le avisan por correo). Puede seguir planeando y redactando la pieza mientras tanto. Devuelve UNA de dos formas: {url} -- OAuth hosteado por Postproxy, el usuario tiene que abrirlo y completarlo a mano (el agente no puede terminar un OAuth por su cuenta), pásale esa URL tal cual; o {needsPageSelection, profileId, profileLabel, pages} -- pasa con facebook y linkedin, cuando ya hay un login de esa red conectado en la cuenta (un solo login da acceso a varias Pages de Facebook, o al perfil personal y las Organization Pages de LinkedIn, no hace falta repetir el OAuth) y hace falta elegir cuál corresponde a este canal. Esa elección hoy es un flujo de la web (Cuentas conectadas) -- si te llega `needsPageSelection`, dile al usuario que complete la selección ahí, esta tool no tiene una contraparte para elegir la página. Modelo real de Postproxy: cada perfil (login) vive en UN solo profile group y Orquestra usa un group por proyecto -- el OAuth de esta tool deja el perfil nuevo en el group de este proyecto; una cuenta de Instagram/TikTok conectada desde otro proyecto NO se puede reusar acá (sería publicar en la cuenta de otro proyecto), hay que conectar la cuenta correcta. Solo para los tipos que soporta Postproxy -- un canal newsletter (Buttondown) se conecta desde la web, no con esta tool.',
|
|
626
812
|
inputSchema: {
|
|
627
813
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
628
814
|
channelId: z.string().describe('Canal a conectar (ver list_channels) -- su `type` tiene que ser facebook/instagram/tiktok/linkedin'),
|
|
@@ -1060,7 +1246,7 @@ export function createServer() {
|
|
|
1060
1246
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
1061
1247
|
ideaId: z.string().describe('Idea -- usá search para encontrar su id'),
|
|
1062
1248
|
text: z.string().describe('Texto del comentario'),
|
|
1063
|
-
authorName: z.string().optional().describe('Default "IA" -- solo cambialo si el comentario es una cita textual de otra persona, no lo tuyo.'),
|
|
1249
|
+
authorName: z.string().optional().describe('Default: el nombre del agente registrado al instalar el MCP ("IA" si no hay) -- solo cambialo si el comentario es una cita textual de otra persona, no lo tuyo.'),
|
|
1064
1250
|
},
|
|
1065
1251
|
},
|
|
1066
1252
|
async ({ projectId, ideaId, text, authorName }) => {
|
|
@@ -1836,7 +2022,7 @@ export function createServer() {
|
|
|
1836
2022
|
description: 'Aplica el Platform Playbook de publicación (iOS App Store / Google Play) sobre una distribución real: crea el módulo + tareas de la plantilla (igual que apply_stage_playbook) y, si mandás publishForm, también completa el formulario de la Guía de publicación de esa distribución (mismo dato que llena DistributionGuide.jsx en la web). Solo completá campos para los que tengas una base real del proyecto (código, get_project_context, servicios/entornos configurados) -- nunca inventes pricing, availability, package/bundle ID, cuentas demo para el reviewer, o el nombre exacto de la app; dejalos afuera de publishForm para que el usuario los complete a mano. Si la distribución YA tiene valores guardados en alguna de las claves que mandás, la tool NO los sobreescribe en silencio -- devuelve publishFormConflict con el valor actual vs. el propuesto por cada clave en conflicto, sin tocar publishForm. Mostrale ese conflicto al usuario y volvé a llamar con confirmReplace:true SOLO si confirma explícitamente el reemplazo. Si este playbook YA se aplicó antes sobre esta misma distribución (módulo+tareas ya creados), la tool lo detecta sola (mismo mecanismo que el badge "Ya aplicado" de DistributionGuide.jsx) y NO vuelve a crear módulo/tareas duplicados -- devuelve alreadyApplied:true. Volvé a llamar con force:true SOLO si el usuario pide explícitamente un checklist nuevo encima del que ya existe.',
|
|
1837
2023
|
inputSchema: {
|
|
1838
2024
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
1839
|
-
platform: z.enum(['ios', 'android']).describe('ios (App Store Connect) | android (Google Play Console) -- ver get_platform_playbook para el contenido'),
|
|
2025
|
+
platform: z.enum(['ios', 'android', 'web']).describe('ios (App Store Connect) | android (Google Play Console) | web (SEO y analítica, sin formulario) -- ver get_platform_playbook para el contenido'),
|
|
1840
2026
|
distributionId: z.string().describe('Distribución real sobre la que aplicar el playbook (ver list_distributions) -- ahí vive el publishForm existente, si lo hay.'),
|
|
1841
2027
|
moduleName: z.string().optional().describe('Nombre del módulo -- default: el de la plantilla'),
|
|
1842
2028
|
publishForm: z.record(z.string()).optional().describe('Respuestas a completar, clave = key de referenceGuide (ver get_platform_playbook). Omitido o vacío = solo crea módulo + tareas, sin tocar el formulario.'),
|
|
@@ -1890,7 +2076,9 @@ export function createServer() {
|
|
|
1890
2076
|
result.markedApplied = true
|
|
1891
2077
|
}
|
|
1892
2078
|
|
|
1893
|
-
if (publishForm && Object.keys(publishForm).length > 0) {
|
|
2079
|
+
if (publishForm && Object.keys(publishForm).length > 0 && playbook.hasForm === false) {
|
|
2080
|
+
result.publishFormIgnored = 'La guía web no tiene formulario de publicación (no hay consola que declarar) -- publishForm se ignoró.'
|
|
2081
|
+
} else if (publishForm && Object.keys(publishForm).length > 0) {
|
|
1894
2082
|
const distributions = await apiGet(config, '/v1/distributions', { projectId: pid })
|
|
1895
2083
|
const dist = distributions.items.find(d => d.id === distributionId)
|
|
1896
2084
|
if (!dist) {
|
|
@@ -1924,5 +2112,96 @@ export function createServer() {
|
|
|
1924
2112
|
}
|
|
1925
2113
|
)
|
|
1926
2114
|
|
|
2115
|
+
// ═══ IDs de medición (GA4 / Meta Pixel / GTM) de una distribución web ═══
|
|
2116
|
+
// Viven en distributions.publishForm (mismo mapa que la Guía de publicación,
|
|
2117
|
+
// ver TRACKING_TOOLS en platformPlaybooks.mjs). PATCH reemplaza el mapa
|
|
2118
|
+
// completo, así que set_tracking_ids lee y mezcla antes de escribir.
|
|
2119
|
+
server.registerTool(
|
|
2120
|
+
'get_tracking_ids',
|
|
2121
|
+
{
|
|
2122
|
+
description: 'Lee los IDs de medición conectados a las distribuciones del proyecto: Google Analytics 4 (ID de medición G-…), Meta Pixel (ID numérico) y Google Tag Manager (GTM-…). Úsalo ANTES de instalar o editar analítica/anuncios en el código de un sitio: si hay un ID guardado, ese es el que debes usar -- nunca inventes ni pidas de nuevo uno que ya está. Devuelve por distribución los IDs presentes y cuáles faltan.',
|
|
2123
|
+
inputSchema: {
|
|
2124
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
2125
|
+
distributionId: z.string().optional().describe('Solo esta distribución (ver list_distributions). Omitido = todas las de tipo web.'),
|
|
2126
|
+
},
|
|
2127
|
+
},
|
|
2128
|
+
async ({ projectId, distributionId }) => {
|
|
2129
|
+
try {
|
|
2130
|
+
const pid = resolveProjectId(projectId)
|
|
2131
|
+
const { items } = await apiGet(config, '/v1/distributions', { projectId: pid })
|
|
2132
|
+
const dists = items.filter(d => distributionId ? d.id === distributionId : d.type === 'web')
|
|
2133
|
+
if (distributionId && dists.length === 0) {
|
|
2134
|
+
throw new Error(`No se encontró la distribución "${distributionId}" en este proyecto -- revisá list_distributions.`)
|
|
2135
|
+
}
|
|
2136
|
+
return textResult({
|
|
2137
|
+
distributions: dists.map(d => {
|
|
2138
|
+
const form = d.publishForm || {}
|
|
2139
|
+
return {
|
|
2140
|
+
distributionId: d.id,
|
|
2141
|
+
name: d.name || d.label,
|
|
2142
|
+
type: d.type,
|
|
2143
|
+
domain: d.domain || null,
|
|
2144
|
+
ids: Object.fromEntries(TRACKING_TOOLS.map(t => [t.key, form[t.key] || null])),
|
|
2145
|
+
missing: TRACKING_TOOLS.filter(t => !form[t.key]).map(t => t.key),
|
|
2146
|
+
}
|
|
2147
|
+
}),
|
|
2148
|
+
tools: TRACKING_TOOLS.map(t => ({ key: t.key, label: t.label, idLabel: t.idLabel, example: t.example, where: t.where })),
|
|
2149
|
+
})
|
|
2150
|
+
} catch (err) {
|
|
2151
|
+
return errorResult(err)
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
)
|
|
2155
|
+
|
|
2156
|
+
server.registerTool(
|
|
2157
|
+
'set_tracking_ids',
|
|
2158
|
+
{
|
|
2159
|
+
description: 'Guarda (o quita) los IDs de medición de una distribución web: ga4_measurement_id (G-…), meta_pixel_id (solo dígitos) y gtm_container_id (GTM-…). Mezcla con lo ya guardado -- solo toca las claves que mandas; "" quita una. Valida el formato. SOLO guarda un ID que el usuario te dio o que leíste literal en el código/consola real del sitio -- nunca lo inventes. Si ya hay un valor distinto guardado y no mandas confirmReplace:true, no escribe y devuelve el conflicto: muéstraselo al usuario y repite con confirmReplace:true solo si confirma.',
|
|
2160
|
+
inputSchema: {
|
|
2161
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
2162
|
+
distributionId: z.string().describe('Distribución web (ver list_distributions)'),
|
|
2163
|
+
ga4_measurement_id: z.string().optional().describe('ID de medición de GA4, ej. G-ABC123DEF4. "" lo quita.'),
|
|
2164
|
+
meta_pixel_id: z.string().optional().describe('ID numérico del Meta Pixel. "" lo quita.'),
|
|
2165
|
+
gtm_container_id: z.string().optional().describe('ID del contenedor de Tag Manager, ej. GTM-ABC1234. "" lo quita.'),
|
|
2166
|
+
confirmReplace: z.boolean().optional().describe('Default false. true solo si el usuario ya confirmó reemplazar un valor existente que te devolvió el conflicto.'),
|
|
2167
|
+
},
|
|
2168
|
+
},
|
|
2169
|
+
async ({ projectId, distributionId, confirmReplace, ...ids }) => {
|
|
2170
|
+
try {
|
|
2171
|
+
const pid = resolveProjectId(projectId)
|
|
2172
|
+
const { items } = await apiGet(config, '/v1/distributions', { projectId: pid })
|
|
2173
|
+
const dist = items.find(d => d.id === distributionId)
|
|
2174
|
+
if (!dist) throw new Error(`No se encontró la distribución "${distributionId}" en este proyecto -- revisá list_distributions.`)
|
|
2175
|
+
|
|
2176
|
+
const existing = dist.publishForm || {}
|
|
2177
|
+
const patch = {}
|
|
2178
|
+
const invalid = []
|
|
2179
|
+
for (const t of TRACKING_TOOLS) {
|
|
2180
|
+
if (ids[t.key] === undefined) continue
|
|
2181
|
+
const raw = String(ids[t.key]).trim()
|
|
2182
|
+
const v = t.key === 'meta_pixel_id' ? raw : raw.toUpperCase()
|
|
2183
|
+
if (v && !t.pattern.test(v)) invalid.push({ key: t.key, value: raw, expected: t.example })
|
|
2184
|
+
else patch[t.key] = v
|
|
2185
|
+
}
|
|
2186
|
+
if (invalid.length) throw new Error(`Formato inválido: ${invalid.map(i => `${i.key} "${i.value}" (ej. ${i.expected})`).join(', ')}`)
|
|
2187
|
+
if (Object.keys(patch).length === 0) throw new Error('No mandaste ningún ID para guardar.')
|
|
2188
|
+
|
|
2189
|
+
const conflicts = Object.entries(patch)
|
|
2190
|
+
.filter(([k, v]) => existing[k] && existing[k] !== v)
|
|
2191
|
+
.map(([key, proposed]) => ({ key, current: existing[key], proposed: proposed || '(quitar)' }))
|
|
2192
|
+
if (conflicts.length > 0 && !confirmReplace) {
|
|
2193
|
+
return textResult({ updated: false, conflicts, message: 'Ya hay valores guardados distintos. No se escribió nada -- muéstraselos al usuario y repite con confirmReplace:true solo si confirma el reemplazo.' })
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
const merged = { ...existing }
|
|
2197
|
+
for (const [k, v] of Object.entries(patch)) { if (v) merged[k] = v; else delete merged[k] }
|
|
2198
|
+
await apiPatch(config, '/v1/distributions', { projectId: pid, id: distributionId, publishForm: merged })
|
|
2199
|
+
return textResult({ updated: true, distributionId, written: patch })
|
|
2200
|
+
} catch (err) {
|
|
2201
|
+
return errorResult(err)
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
)
|
|
2205
|
+
|
|
1927
2206
|
return server
|
|
1928
2207
|
}
|