orquestra-mcp 1.20.0 → 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 +20 -1
- package/src/server.mjs +214 -1
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: {
|
|
@@ -288,6 +297,11 @@ export const PLATFORM_PLAYBOOKS = {
|
|
|
288
297
|
title: 'Google Analytics 4 / Firebase Analytics',
|
|
289
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.',
|
|
290
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
|
+
},
|
|
291
305
|
{
|
|
292
306
|
key: 'bing_webmaster',
|
|
293
307
|
title: 'Bing Webmaster Tools (opcional)',
|
|
@@ -339,7 +353,12 @@ export const PLATFORM_PLAYBOOKS = {
|
|
|
339
353
|
{
|
|
340
354
|
title: 'Instalar analítica (Google Analytics 4 / Firebase Analytics) y definir una conversión',
|
|
341
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).',
|
|
342
|
-
type: 'tech', priority: 'high', tags: ['seo', 'web', 'analytics'], offsetDays: 2,
|
|
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',
|
|
343
362
|
},
|
|
344
363
|
{
|
|
345
364
|
title: 'Verificar que sitemap.xml y robots.txt respondan 200 y no bloqueen el sitio',
|
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.
|
|
@@ -613,6 +615,126 @@ 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
|
+
|
|
616
738
|
// Recordatorio con hora de una tarea (2 oct 2026, a pedido de José) --
|
|
617
739
|
// "recuérdame en 5 horas revisar X con Ana" / "mañana revisa Y para poder
|
|
618
740
|
// cerrarla". Azúcar sobre PATCH /v1/tasks { remindAt (epoch ms), remindNote }:
|
|
@@ -1990,5 +2112,96 @@ export function createServer() {
|
|
|
1990
2112
|
}
|
|
1991
2113
|
)
|
|
1992
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
|
+
|
|
1993
2206
|
return server
|
|
1994
2207
|
}
|