orquestra-mcp 1.23.1 → 1.27.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 +5 -2
- package/package.json +2 -2
- package/src/api.mjs +13 -0
- package/src/server.mjs +212 -11
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 **100 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 —
|
|
@@ -111,6 +111,9 @@ Lectura de contexto:
|
|
|
111
111
|
| `list_tasks` | `projectId?`, `statusId?` | Lista de tareas, filtro opcional por estatus |
|
|
112
112
|
| `search` | `projectId?`, `q` | Búsqueda por texto en tareas/notas/ideas/docs |
|
|
113
113
|
| `get_dashboard_summary` | `projectId?` | Hoy/vencidas/bloqueadas, salud de módulos, actividad reciente |
|
|
114
|
+
| `get_project_findings` | `projectId?` | Qué le falta al proyecto, priorizado por severidad (determinístico, sin créditos de IA) |
|
|
115
|
+
| `ask_assistant` | `question`, `history?`, `projectId?` | Pregunta al Asistente sobre el proyecto; responde con propuestas (1 crédito de IA) |
|
|
116
|
+
| `assess_project` | `projectId?` | Resumen y prioridades del proyecto con IA; se reusa 1 h si no cambian los hallazgos |
|
|
114
117
|
| `get_infra_status` | `projectId?` | Servicios, entornos y distribuciones con conteos operational/degraded/down |
|
|
115
118
|
| `get_parity_summary` | `projectId?` | Desfases de paridad multiplataforma (tasks y modules por separado) |
|
|
116
119
|
| `get_workspace_summary` | — | Progreso agregado de todos los proyectos del workspace |
|
|
@@ -192,7 +195,7 @@ create/update sigue siendo criterio del agente y del humano.
|
|
|
192
195
|
|
|
193
196
|
## Estado
|
|
194
197
|
|
|
195
|
-
|
|
198
|
+
100 tools: lectura (contexto, dashboard, workspace, infra, paridad, standup,
|
|
196
199
|
sync report, setup guide, playbooks de etapa y de plataforma, catálogo de
|
|
197
200
|
skills, search) + `list_*` de cada colección + creación/edición de tareas
|
|
198
201
|
(con checklist, tiempo, adjuntos, merge), ideas (con comentarios), notas,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orquestra-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Servidor MCP de Orquestra --
|
|
3
|
+
"version": "1.27.0",
|
|
4
|
+
"description": "Servidor MCP de Orquestra -- 112 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": {
|
|
7
7
|
"orquestra-mcp": "bin/orquestra-mcp.mjs"
|
package/src/api.mjs
CHANGED
|
@@ -94,6 +94,19 @@ export async function apiPatch(config, path, { projectId, id, ...body }) {
|
|
|
94
94
|
return parseResponse(res)
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Mismo método que apiPatch, pero para un doc singleton SIN id en la ruta
|
|
98
|
+
// (ej. /v1/content-config) -- set_hourly_rate (8 oct 2026, ORQ-247) es el
|
|
99
|
+
// primer caso real.
|
|
100
|
+
export async function apiPatchPath(config, path, { projectId, ...body }) {
|
|
101
|
+
const url = buildUrl(config, path, { projectId })
|
|
102
|
+
const res = await fetch(url, {
|
|
103
|
+
method: 'PATCH',
|
|
104
|
+
headers: { Authorization: `Bearer ${config.apiToken}`, 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify(body),
|
|
106
|
+
})
|
|
107
|
+
return parseResponse(res)
|
|
108
|
+
}
|
|
109
|
+
|
|
97
110
|
// Único método DELETE del cliente (21 ago 2026, para delete_checklist_item
|
|
98
111
|
// -- ver CLAUDE.md, "único delete_* real de la API/MCP"). Mismo shape que
|
|
99
112
|
// apiPatch (arma la URL como `${path}/${id}`), sin body -- ningún delete de
|
package/src/server.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
5
5
|
import { z } from 'zod'
|
|
6
|
-
import { loadConfig, apiGet, apiPost, apiPatch, apiDelete, putToPresignedUrl, ApiError } from './api.mjs'
|
|
6
|
+
import { loadConfig, apiGet, apiPost, apiPatch, apiPatchPath, apiDelete, putToPresignedUrl, ApiError } from './api.mjs'
|
|
7
7
|
import { STAGES, PLAYBOOKS } from './stages.mjs'
|
|
8
8
|
import { FOUNDATIONS } from './foundations.mjs'
|
|
9
9
|
import { PLATFORM_PLAYBOOKS, TRACKING_TOOLS } from './platformPlaybooks.mjs'
|
|
@@ -63,7 +63,7 @@ const SETUP_GUIDE = {
|
|
|
63
63
|
{ step: 3, section: 'Infraestructura', check: 'list_services / list_environments / list_distributions (o get_infra_status para los 3 juntos)', createOrUpdate: 'create_service|environment|distribution / update_*', matchBy: 'name (o type para servicios/distribuciones)' },
|
|
64
64
|
{ step: 4, section: 'Módulos', check: 'list_modules', createOrUpdate: 'create_module / update_module', matchBy: 'name', dependsOn: ['statusId (paso 1)', 'categoryId (paso 2)'] },
|
|
65
65
|
{ step: 5, section: 'Tareas', check: 'list_tasks', createOrUpdate: 'create_task / update_task', matchBy: 'title', dependsOn: ['statusId', 'categoryId', 'moduleId'] },
|
|
66
|
-
{ step: 6, section: 'Ideas', check: '
|
|
66
|
+
{ step: 6, section: 'Ideas', check: 'list_ideas', createOrUpdate: 'create_idea / update_idea / convert_idea_to_task', matchBy: 'title' },
|
|
67
67
|
{ step: 7, section: 'Notas', check: 'list_notes', createOrUpdate: 'create_note / update_note', matchBy: 'title' },
|
|
68
68
|
{ step: 8, section: 'Changelog', check: 'list_changelog', createOrUpdate: 'create_changelog_entry / update_changelog_entry', matchBy: 'text' },
|
|
69
69
|
{ step: 9, section: 'Docs', check: 'search', createOrUpdate: 'create_doc (sin update_doc todavía)', matchBy: 'title' },
|
|
@@ -117,7 +117,7 @@ No le preguntes al usuario por workspaceId ni projectId -- ya están configurado
|
|
|
117
117
|
|
|
118
118
|
No crees tareas ni entradas de changelog por preguntas triviales o exploración sin cambios reales -- solo por trabajo que de verdad avanza el proyecto.
|
|
119
119
|
|
|
120
|
-
Si estás iniciando una sesión de trabajo real en un proyecto que YA tiene código (no un "vacío" recién conectado, ver arriba) y hace rato no se corrió este chequeo, o el usuario pide explícito "revisa fundamentos"/"qué le falta a este proyecto" (no lo hagas en cada mensaje ni en exploración sin cambios): llamá a get_foundations_checklist y, con acceso real al filesystem del repo (grep de dependencias, archivos de config, workflows de CI -- seguí el detectionHint de cada ítem), confirmá cuáles de verdad faltan -- filtrá por "scales" contra el scale real del proyecto (ver project.scale en get_project_context; un ítem con scales:["platform"] no aplica a una landing). Por cada uno que confirmes ausente con evidencia concreta, llamá a report_foundation_gap con esa evidencia específica -- nunca lo reportes solo porque el checklist lo menciona, sin haber revisado el código de verdad. No repitas un chequeo ya hecho recientemente en la misma sesión.
|
|
120
|
+
Si estás iniciando una sesión de trabajo real en un proyecto que YA tiene código (no un "vacío" recién conectado, ver arriba) y hace rato no se corrió este chequeo, o el usuario pide explícito "revisa fundamentos"/"qué le falta a este proyecto" (no lo hagas en cada mensaje ni en exploración sin cambios): primero llamá a get_project_findings -- ya trae, calculado por el servidor y priorizado, lo que falta o está mal dentro de Orquestra (vencidas, bloqueadas, módulos en riesgo, paridad, infraestructura, servicios que faltan, duplicadas) -- y preséntaselo al usuario por severidad antes de seguir; luego, para lo que solo se ve en el código, llamá a get_foundations_checklist y, con acceso real al filesystem del repo (grep de dependencias, archivos de config, workflows de CI -- seguí el detectionHint de cada ítem), confirmá cuáles de verdad faltan -- filtrá por "scales" contra el scale real del proyecto (ver project.scale en get_project_context; un ítem con scales:["platform"] no aplica a una landing). Por cada uno que confirmes ausente con evidencia concreta, llamá a report_foundation_gap con esa evidencia específica -- nunca lo reportes solo porque el checklist lo menciona, sin haber revisado el código de verdad. No repitas un chequeo ya hecho recientemente en la misma sesión.
|
|
121
121
|
|
|
122
122
|
Si el usuario pregunta explícito qué Claude Skills de terceros le faltan (o pide instalar alguna) -- no lo hagas sin que te lo pidan: llamá a get_skills_catalog y revisá con acceso real al filesystem cuáles de esos keys ya existen en .claude/skills/ (del proyecto) o ~/.claude/skills/ (global). Antes de instalar cualquiera con install_skill, llamá primero a preview_skill y mostrale al usuario el skillMdContent completo -- nunca instales sin que haya visto ese contenido y confirmado explícitamente, los SKILL.md de terceros pueden traer instrucciones que un agente ejecuta solo con cargarlas.`
|
|
123
123
|
|
|
@@ -228,9 +228,29 @@ export function createServer() {
|
|
|
228
228
|
registerRead(
|
|
229
229
|
'search',
|
|
230
230
|
'/v1/search',
|
|
231
|
-
'Busca por texto en tareas, notas, ideas y docs de un proyecto de Orquestra.',
|
|
232
|
-
{
|
|
233
|
-
|
|
231
|
+
'Busca por texto en tareas, notas, ideas y docs de un proyecto de Orquestra. Tope de 30 resultados con las tareas primero: si buscas ideas o docs en un proyecto con muchas tareas, pasa `types` para que no queden tapadas.',
|
|
232
|
+
{
|
|
233
|
+
q: z.string().describe('Texto a buscar'),
|
|
234
|
+
types: z.array(z.enum(['task', 'note', 'idea', 'doc'])).optional().describe('Limitar a estos tipos (opcional). Sin esto busca en los cuatro.'),
|
|
235
|
+
},
|
|
236
|
+
(args, projectId) => ({ projectId, q: args.q, types: args.types?.join(',') })
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
registerRead(
|
|
240
|
+
'list_ideas',
|
|
241
|
+
'/v1/ideas',
|
|
242
|
+
'Lista las ideas de un proyecto de Orquestra. Por default excluye las hechas y descartadas (mismo criterio que list_tasks): pide un `status` puntual (se respeta tal cual, incluso uno cerrado) o includeClosed=true para verlas. Filtros AND por módulo, prioridad y origen.',
|
|
243
|
+
{
|
|
244
|
+
status: z.enum(IDEA_STATUSES).optional().describe('Un estatus puntual (opcional)'),
|
|
245
|
+
includeClosed: z.boolean().optional().describe('true para incluir también hechas y descartadas'),
|
|
246
|
+
moduleId: z.string().optional().describe('ID de módulo para filtrar (opcional)'),
|
|
247
|
+
priority: z.enum(TASK_PRIORITIES).optional().describe('Prioridad para filtrar (opcional)'),
|
|
248
|
+
origin: z.enum(['cliente', 'soporte', 'equipo', 'ventas']).optional().describe('Origen para filtrar (opcional)'),
|
|
249
|
+
},
|
|
250
|
+
(args, projectId) => ({
|
|
251
|
+
projectId, status: args.status, includeClosed: args.includeClosed ? 'true' : undefined,
|
|
252
|
+
moduleId: args.moduleId, priority: args.priority, origin: args.origin,
|
|
253
|
+
})
|
|
234
254
|
)
|
|
235
255
|
|
|
236
256
|
// ═══ LECTURA (nivel A -- dashboard, workspace, infra, paridad, playbooks) ═══
|
|
@@ -243,6 +263,31 @@ export function createServer() {
|
|
|
243
263
|
(args, projectId) => ({ projectId })
|
|
244
264
|
)
|
|
245
265
|
|
|
266
|
+
registerRead(
|
|
267
|
+
'get_project_findings',
|
|
268
|
+
'/v1/findings',
|
|
269
|
+
'Qué le falta o qué está mal en un proyecto, ya calculado por el servidor y priorizado por severidad (high/medium/low): tareas vencidas o bloqueadas, módulos en riesgo, tareas sin módulo/fecha, referencias rotas, desfases de paridad, infraestructura caída, servicios que faltan (backend/base de datos/auth/analytics/push), posibles tareas duplicadas y fundamentos olvidados que ya se reportaron. Cada hallazgo trae título, detalle, entidades afectadas (con id) y una acción sugerida. Es determinístico, rápido y no consume créditos de IA. Lo ya descartado o aceptado como sugerencia no aparece.',
|
|
270
|
+
{},
|
|
271
|
+
(args, projectId) => ({ projectId })
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
registerCreate(
|
|
275
|
+
'ask_assistant',
|
|
276
|
+
'/v1/assistant/ask',
|
|
277
|
+
'Pregúntale al Asistente de Orquestra sobre el proyecto (qué hacer hoy, qué está en riesgo, por qué algo está atorado). El servidor arma el contexto real del proyecto -- tareas abiertas, hallazgos, módulos -- y responde { answer, proposals } citando tareas por código. Consume 1 crédito de IA del workspace por pregunta; si solo necesitas saber qué falta, usa get_project_findings, que es gratis. Solo recomienda: no cambia nada en el proyecto.',
|
|
278
|
+
{
|
|
279
|
+
question: z.string().describe('La pregunta, en lenguaje natural (máx. 1000 caracteres)'),
|
|
280
|
+
history: z.array(z.object({ role: z.enum(['user', 'assistant']), text: z.string() })).optional().describe('Turnos previos de la conversación, si hay'),
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
registerCreate(
|
|
285
|
+
'assess_project',
|
|
286
|
+
'/v1/assistant/assess',
|
|
287
|
+
'Diagnóstico corto del proyecto con IA: un resumen del estado y de 1 a 3 prioridades, partiendo de los hallazgos de get_project_findings. El servidor lo guarda 1 hora mientras los hallazgos no cambien, así que repetirlo es gratis; solo cobra 1 crédito de IA cuando lo recalcula. Si no hay hallazgos no usa IA.',
|
|
288
|
+
{}
|
|
289
|
+
)
|
|
290
|
+
|
|
246
291
|
registerRead(
|
|
247
292
|
'get_infra_status',
|
|
248
293
|
'/v1/infra',
|
|
@@ -290,6 +335,54 @@ export function createServer() {
|
|
|
290
335
|
}
|
|
291
336
|
)
|
|
292
337
|
|
|
338
|
+
// Config "suelta" que hasta el 8 oct 2026 solo se podía tocar desde la
|
|
339
|
+
// web (ver workspaceSettings.mjs en orquestra-infra) -- José pidió poder
|
|
340
|
+
// darla de alta también por acá.
|
|
341
|
+
server.registerTool(
|
|
342
|
+
'set_slack_webhook',
|
|
343
|
+
{
|
|
344
|
+
description: 'Configura (o quita, con url:"") el webhook de Slack del workspace -- cada notificación (tareas asignadas, QA, infra, contenido...) también se manda ahí, además de in-app/push/correo. Requiere un token de servicio SIN projectId (mismo criterio que get_workspace_summary/init_project) -- un token scopeado a un proyecto puntual no puede tocar esto.',
|
|
345
|
+
inputSchema: {
|
|
346
|
+
url: z.string().describe('URL del Incoming Webhook de Slack (https://hooks.slack.com/services/...), o "" para desactivarlo'),
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
async ({ url }) => {
|
|
350
|
+
try {
|
|
351
|
+
const data = await apiPatch(config, '/v1/workspaces', { id: config.workspaceId, slackWebhookUrl: url })
|
|
352
|
+
return textResult(data)
|
|
353
|
+
} catch (err) {
|
|
354
|
+
return errorResult(err)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
registerRead('get_content_budget', '/v1/content-config',
|
|
360
|
+
'Lee los topes numéricos de presupuesto/tarifa de Inversión de Contenido de un proyecto (hourlyRate, monthlyBudget, dailyCap, perCampaignCap, alertThresholdPct). No trae el resto de contentConfig (guía de marca, imágenes, fragmentos aprobados) -- eso es contenido rico pensado para autoría humana en la web.',
|
|
361
|
+
{}, (args, projectId) => ({ projectId }))
|
|
362
|
+
|
|
363
|
+
server.registerTool(
|
|
364
|
+
'set_hourly_rate',
|
|
365
|
+
{
|
|
366
|
+
description: 'Configura la tarifa por hora (u otros topes numéricos de presupuesto de Inversión de Contenido) de un proyecto -- alimenta "Horas del equipo" (Σ timeEntries del mes en curso × esta tarifa) y el reporte de horas facturables de la web (sección Horas). Al menos un campo es obligatorio.',
|
|
367
|
+
inputSchema: {
|
|
368
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
369
|
+
hourlyRate: z.number().optional().describe('Tarifa por hora única del proyecto (misma moneda que el resto de Inversión)'),
|
|
370
|
+
monthlyBudget: z.number().optional().describe('Presupuesto asignado del mes'),
|
|
371
|
+
dailyCap: z.number().optional().describe('Tope diario -- informativo, no se aplica solo'),
|
|
372
|
+
perCampaignCap: z.number().optional().describe('Tope por campaña -- sí se aplica solo (pausa la campaña al llegar acá)'),
|
|
373
|
+
alertThresholdPct: z.number().optional().describe('Default 80 -- aviso cuando el gasto llega a este % del monthlyBudget'),
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
async ({ projectId, ...body }) => {
|
|
377
|
+
try {
|
|
378
|
+
const data = await apiPatchPath(config, '/v1/content-config', { projectId: resolveProjectId(projectId), ...body })
|
|
379
|
+
return textResult(data)
|
|
380
|
+
} catch (err) {
|
|
381
|
+
return errorResult(err)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
|
|
293
386
|
registerRead(
|
|
294
387
|
'list_modules',
|
|
295
388
|
'/v1/modules',
|
|
@@ -516,7 +609,8 @@ export function createServer() {
|
|
|
516
609
|
scope: z.array(z.object({ distId: z.string(), layer: z.string().optional(), done: z.boolean() })).optional()
|
|
517
610
|
.describe('Alcance por distribución (paridad multiplataforma) -- para setearlo desde la creación en vez de crear y después update_task. Usa get_parity_summary/list_distributions para ver qué distId existen. Si una distId tiene hermanas (mismo productKey), el server suma las que falten solo, con done:false -- la respuesta trae parityAddedDistIds con lo que agregó: avísale al usuario que la tarea también quedó pendiente en esas plataformas.'),
|
|
518
611
|
skipParity: z.boolean().optional().describe('true = NO sumar las plataformas hermanas al scope aunque tengan el mismo productKey. Solo cuando el trabajo es genuinamente de UNA plataforma (ej. un ajuste de Gradle que no tiene contraparte en iOS) -- no lo mandes por default. Las tareas type="bug" nunca propagan, sin necesidad de esto.'),
|
|
519
|
-
relatedTaskId: z.string().optional().describe('"Bloqueada por" -- id de otra tarea.
|
|
612
|
+
relatedTaskId: z.string().optional().describe('"Bloqueada por" -- id de otra tarea. LEGACY desde ORQ-248: si vas a bloquear por más de una tarea, usa blockedByIds en vez de esto (relatedTaskId se sigue llenando solo, como blockedByIds[0], para paridad con iOS/Android).'),
|
|
613
|
+
blockedByIds: z.array(z.string()).optional().describe('"Bloqueada por" -- ids de otras tareas (puede ser más de una). Esta tarea se muestra como bloqueada mientras ALGUNA de esas no llegue a un estatus final (isFinal). Reemplaza a relatedTaskId para bloqueos múltiples -- preferí este campo, no relatedTaskId, salvo que de verdad sea un solo bloqueador.'),
|
|
520
614
|
location: z.string().optional().describe('Solo relevante para type "meeting"/"call" (plataforma o lugar, ej. "Zoom", "Oficina") o "followup"/"business" (contexto libre) -- ignóralo para el resto de los tipos.'),
|
|
521
615
|
estimatedDays: z.number().optional().describe('Estimación en días de trabajo.'),
|
|
522
616
|
stack: z.array(z.string()).optional().describe('Tecnologías involucradas (mismo vocabulario que el stack de una distribución, ej. "node", "react", "postgres").'),
|
|
@@ -551,7 +645,8 @@ export function createServer() {
|
|
|
551
645
|
tags: z.array(z.string()).optional(),
|
|
552
646
|
scope: z.array(z.object({ distId: z.string(), layer: z.string().optional(), done: z.boolean() })).optional()
|
|
553
647
|
.describe('Alcance por distribución -- usa get_parity_summary para ver el scope actual antes de editarlo'),
|
|
554
|
-
relatedTaskId: z.string().optional().describe('"Bloqueada por" -- id de otra tarea, o "" para quitar el bloqueo.
|
|
648
|
+
relatedTaskId: z.string().optional().describe('"Bloqueada por" -- id de otra tarea, o "" para quitar el bloqueo. LEGACY, ver create_task -- preferí blockedByIds.'),
|
|
649
|
+
blockedByIds: z.array(z.string()).optional().describe('"Bloqueada por" -- reemplaza el array COMPLETO (no hace merge), [] quita todos los bloqueos. Ver create_task.'),
|
|
555
650
|
location: z.string().optional().describe('Solo relevante para type "meeting"/"call"/"followup"/"business". Ver create_task.'),
|
|
556
651
|
comment: z.string().optional().describe('Explica qué hiciste o por qué cambia la tarea -- queda como comentario visible en ella. Obligatorio en la práctica cuando mandas statusId: sin esto, alguien ve que la tarea cambió de estatus pero no por qué.'),
|
|
557
652
|
estimatedDays: z.number().optional().describe('Estimación en días de trabajo.'),
|
|
@@ -829,7 +924,7 @@ export function createServer() {
|
|
|
829
924
|
server.registerTool(
|
|
830
925
|
'publish_content_postproxy',
|
|
831
926
|
{
|
|
832
|
-
description: 'Publica o programa una pieza de Contenido en redes sociales vía Postproxy -- solo funciona si la pieza tiene al menos una variante en estatus "approved" y su canal ya está conectado (ver connect_postproxy_channel/list_channels). Respeta approvalRequired -- no la llames para saltarte una aprobación pendiente. Cada variante aprobada va en SU PROPIO post de Postproxy (caption/media/fecha propios); antes de cada uno el server verifica el perfil EN VIVO y rechaza con un 400 claro si la cuenta está vencida, ya no existe, o (Instagram/TikTok) vive en el group de Postproxy de otro proyecto -- es decir, el canal apunta a la cuenta de otro proyecto y hay que reconectarlo. Devuelve {posts:[{channelId, postproxyPostId, status}], failed:[{channelId, channelName, error}]}: con éxito parcial responde 200 igual, así que revisa `failed` y cuéntaselo al usuario -- cada variante queda con su externalGuid o su publishError en la pieza.',
|
|
927
|
+
description: 'Publica o programa una pieza de Contenido en redes sociales vía Postproxy -- solo funciona si la pieza tiene al menos una variante en estatus "approved" y su canal ya está conectado (ver connect_postproxy_channel/list_channels). Si el workspace todavía no tiene Postproxy habilitado (BAJO SOLICITUD, ver connect_postproxy_channel), responde 403 `postproxy_not_enabled` -- no reintentes, dile al usuario que un admin lo solicite desde Contenido → Cuentas conectadas → "Solicitar conexión". Respeta approvalRequired -- no la llames para saltarte una aprobación pendiente. Cada variante aprobada va en SU PROPIO post de Postproxy (caption/media/fecha propios); antes de cada uno el server verifica el perfil EN VIVO y rechaza con un 400 claro si la cuenta está vencida, ya no existe, o (Instagram/TikTok) vive en el group de Postproxy de otro proyecto -- es decir, el canal apunta a la cuenta de otro proyecto y hay que reconectarlo. Devuelve {posts:[{channelId, postproxyPostId, status}], failed:[{channelId, channelName, error}]}: con éxito parcial responde 200 igual, así que revisa `failed` y cuéntaselo al usuario -- cada variante queda con su externalGuid o su publishError en la pieza.',
|
|
833
928
|
inputSchema: {
|
|
834
929
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
835
930
|
contentId: z.string().describe('Pieza de Contenido a publicar (ver list_content)'),
|
|
@@ -846,6 +941,79 @@ export function createServer() {
|
|
|
846
941
|
}
|
|
847
942
|
)
|
|
848
943
|
|
|
944
|
+
// sync_content_postproxy / reschedule_content_postproxy (10 oct 2026, a
|
|
945
|
+
// pedido de José: "todo esto que estamos haciendo en web, también se
|
|
946
|
+
// puede hacer por MCP?") -- paridad con los botones "Consultar ahora" y
|
|
947
|
+
// "Reagendar en Postproxy" de ContentDetail.jsx, que hasta ahora solo
|
|
948
|
+
// existían en la web. Mismo criterio que publish_content_postproxy
|
|
949
|
+
// (server.registerTool directo, no CRUD estándar).
|
|
950
|
+
server.registerTool(
|
|
951
|
+
'sync_content_postproxy',
|
|
952
|
+
{
|
|
953
|
+
description: 'Pide el estado REAL de Postproxy para las variantes ya enviadas de una pieza (con externalGuid), en vez de esperar pasivamente el webhook -- usala si una pieza quedó "esperando confirmación" más tiempo del esperado (el webhook de Postproxy se perdió o tardó) o si el usuario pregunta "¿ya se publicó?" y querés confirmarlo ahora en vez de asumir. Actualiza status/publishedAt/postproxyPermalink/publishError en Firestore si Postproxy tiene novedades, y siempre estampa lastPostproxyCheckAt aunque no haya cambios -- también si la pieza ya está agendada a futuro (scheduledAt), en cuyo caso Postproxy SIEMPRE va a responder "todavía no", no es un error ni señal de que algo falló. Devuelve {checked}: cuántas variantes con externalGuid se consultaron (0 si la pieza nunca se envió a Postproxy). Después de llamarla, volvé a leer la pieza con list_content para ver si cambió -- la respuesta de esta tool no trae el contenido actualizado.',
|
|
954
|
+
inputSchema: {
|
|
955
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
956
|
+
contentId: z.string().describe('Pieza de Contenido a consultar (ver list_content)'),
|
|
957
|
+
},
|
|
958
|
+
},
|
|
959
|
+
async ({ projectId, contentId }) => {
|
|
960
|
+
try {
|
|
961
|
+
const data = await apiPost(config, `/v1/content/${contentId}/sync-postproxy`, { projectId: resolveProjectId(projectId) })
|
|
962
|
+
return textResult(data)
|
|
963
|
+
} catch (err) {
|
|
964
|
+
return errorResult(err)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
server.registerTool(
|
|
970
|
+
'reschedule_content_postproxy',
|
|
971
|
+
{
|
|
972
|
+
description: 'Reagenda en Postproxy un canal de una pieza YA enviado (con externalGuid). Cambiar scheduledAt con update_content SOLO escribe Firestore -- el post real en Postproxy sigue en la hora VIEJA hasta llamar esta tool, así que después de mover la fecha con update_content llamá siempre a esta para que el cambio aplique de verdad. Postproxy solo deja editar drafts o posts programados a MÁS de 5 minutos de su hora real de publicación -- devuelve 422 "Post cannot be edited..." si ya se publicó o está a menos de 5 minutos, ese mensaje sube tal cual, no hay forma de evitarlo. Si sale bien, también actualiza scheduledAt en Firestore para esa variante (no hace falta un update_content aparte). channelId tiene que ser un canal que la pieza YA tenga y que ya esté enviado a Postproxy (ver list_content) -- si no tiene externalGuid todavía, falla con 400 explicando que primero hay que publish_content_postproxy.',
|
|
973
|
+
inputSchema: {
|
|
974
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
975
|
+
contentId: z.string().describe('Pieza de Contenido a reagendar (ver list_content)'),
|
|
976
|
+
channelId: z.string().describe('Canal ya enviado a Postproxy (con externalGuid) dentro de esa pieza'),
|
|
977
|
+
scheduledAt: z.number().describe('Nuevo timestamp en ms de cuándo debe publicarse'),
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
async ({ projectId, contentId, channelId, scheduledAt }) => {
|
|
981
|
+
try {
|
|
982
|
+
const data = await apiPost(config, `/v1/content/${contentId}/reschedule-postproxy`, { projectId: resolveProjectId(projectId), channelId, scheduledAt })
|
|
983
|
+
return textResult(data)
|
|
984
|
+
} catch (err) {
|
|
985
|
+
return errorResult(err)
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
// backfill_channel_content (10 oct 2026, a pedido de José: "agregar canal
|
|
991
|
+
// y agregar los contenidos que aún no se publican... para que todos
|
|
992
|
+
// estén parejos") -- recorre TODO el proyecto en un solo llamado, no una
|
|
993
|
+
// pieza a la vez (equivalente en bulk al botón "Copiar contenido acá" de
|
|
994
|
+
// ContentDetail.jsx en la web, que solo opera sobre una pieza). No es CRUD
|
|
995
|
+
// estándar (no crea ni edita UNA entidad con un id propio), así que va
|
|
996
|
+
// con server.registerTool directo, mismo criterio que publish_content_postproxy.
|
|
997
|
+
server.registerTool(
|
|
998
|
+
'backfill_channel_content',
|
|
999
|
+
{
|
|
1000
|
+
description: 'Agrega un canal a TODAS las piezas de Contenido del proyecto que todavía no lo tienen, copiando caption/media/prompts/formato de otro canal que sí los tenga -- para que un canal recién conectado (ej. LinkedIn) no arranque en blanco pieza por pieza. Solo toca piezas pendientes: se SALTEA automáticamente una pieza si el canal fuente ya está en estatus "published" ahí, si el canal fuente no tiene contenido real que copiar, o si la pieza ya tiene el canal destino. NUNCA pisa fecha (scheduledAt)/aprobación/estatus/desempeño -- el canal nuevo arranca siempre en estatus "idea", sin fecha, cada canal programa/aprueba por separado. Devuelve {updated, updatedIds, skipped, skippedDetail:[{id,title,reason}], failed}: revisa `failed` (reintentos que fallaron la escritura) y contale al usuario cuántas piezas se saltearon y por qué, no asumas que "updated" es el total de piezas del proyecto.',
|
|
1001
|
+
inputSchema: {
|
|
1002
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
1003
|
+
channelId: z.string().describe('Canal a agregar donde falte (ver list_channels) -- recién conectado o con pocas piezas todavía'),
|
|
1004
|
+
sourceChannelId: z.string().describe('Canal del que copiar el contenido en cada pieza (ver list_channels) -- tiene que ser distinto de channelId'),
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
async ({ projectId, channelId, sourceChannelId }) => {
|
|
1008
|
+
try {
|
|
1009
|
+
const data = await apiPost(config, '/v1/content/backfill-channel', { projectId: resolveProjectId(projectId), channelId, sourceChannelId })
|
|
1010
|
+
return textResult(data)
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
return errorResult(err)
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
)
|
|
1016
|
+
|
|
849
1017
|
// Checklist de una tarea -- registradas directo con server.registerTool
|
|
850
1018
|
// (no con registerCreate/registerUpdate) porque su ruta necesita DOS ids
|
|
851
1019
|
// dinámicos (taskId + itemId del checklist), y esos helpers solo arman
|
|
@@ -1652,7 +1820,7 @@ export function createServer() {
|
|
|
1652
1820
|
deployMode: z.enum(['auto', 'manual']).optional().describe('Modo de deploy -- web'),
|
|
1653
1821
|
hasPush: z.boolean().optional().describe('Tiene push notifications -- apps (ios/android)'),
|
|
1654
1822
|
storeUrl: z.string().optional(),
|
|
1655
|
-
stack: z.array(z.string()).optional(),
|
|
1823
|
+
stack: z.array(z.string()).optional().describe('Tecnologías de la plataforma. Puedes mandar nombres ("Kotlin", "Jetpack Compose", "Node.js"): la API los normaliza a las claves de su catálogo (schema/enums/TechStack.json en orquestra-infra) y conserva lo que no reconoce.'),
|
|
1656
1824
|
version: z.string().optional().describe('Versión en store'),
|
|
1657
1825
|
reviewVersion: z.string().optional().describe('Build en revisión -- apps'),
|
|
1658
1826
|
repoUrl: z.string().optional().describe('URL del repo de este código (ej. de "git remote get-url origin") -- solo informativo, no requiere credenciales. Distinto de conectar el repo con PAT (eso es Workspace → Conexiones, un paso deliberado del usuario, nunca lo hagas solo).'),
|
|
@@ -1673,7 +1841,7 @@ export function createServer() {
|
|
|
1673
1841
|
deployMode: z.enum(['auto', 'manual']).optional(),
|
|
1674
1842
|
hasPush: z.boolean().optional(),
|
|
1675
1843
|
storeUrl: z.string().optional(),
|
|
1676
|
-
stack: z.array(z.string()).optional(),
|
|
1844
|
+
stack: z.array(z.string()).optional().describe('Tecnologías de la plataforma. Puedes mandar nombres ("Kotlin", "Jetpack Compose", "Node.js"): la API los normaliza a las claves de su catálogo (schema/enums/TechStack.json en orquestra-infra) y conserva lo que no reconoce.'),
|
|
1677
1845
|
version: z.string().optional(),
|
|
1678
1846
|
reviewVersion: z.string().optional(),
|
|
1679
1847
|
repoUrl: z.string().optional().describe('URL del repo de este código -- solo informativo, no requiere credenciales.'),
|
|
@@ -1763,6 +1931,39 @@ export function createServer() {
|
|
|
1763
1931
|
status: z.enum(CAMPAIGN_STATUSES).optional(),
|
|
1764
1932
|
})
|
|
1765
1933
|
|
|
1934
|
+
// Registro de riesgos a nivel de proyecto (ORQ-246, 8 oct 2026) --
|
|
1935
|
+
// distinto de "bloqueada" en una tarea puntual (relatedTaskId/
|
|
1936
|
+
// isTaskBlocked): esto es "qué puede hacer fallar el proyecto" en
|
|
1937
|
+
// general, sin necesitar estar ligado a una tarea.
|
|
1938
|
+
const RISK_LEVELS = ['low', 'medium', 'high']
|
|
1939
|
+
const RISK_STATUSES = ['open', 'mitigating', 'closed']
|
|
1940
|
+
|
|
1941
|
+
registerRead('list_risks', '/v1/risks',
|
|
1942
|
+
'Lista los riesgos/bloqueos a nivel de proyecto (dependencias externas, decisiones pendientes de un cliente, etc. -- no ligados necesariamente a una tarea puntual). Úsala antes de create_risk para no duplicar.',
|
|
1943
|
+
{}, (args, projectId) => ({ projectId }))
|
|
1944
|
+
|
|
1945
|
+
registerCreate('create_risk', '/v1/risks', 'Registra un riesgo/bloqueo a nivel de proyecto -- algo que puede hacer fallar o atrasar el proyecto en general, no el bloqueo puntual de una tarea (para eso usa relatedTaskId en update_task).', {
|
|
1946
|
+
title: z.string(),
|
|
1947
|
+
description: z.string().optional(),
|
|
1948
|
+
probability: z.enum(RISK_LEVELS).optional().describe('Default: medium'),
|
|
1949
|
+
impact: z.enum(RISK_LEVELS).optional().describe('Default: medium'),
|
|
1950
|
+
mitigation: z.string().optional().describe('Plan o texto libre de qué se hace para reducir este riesgo'),
|
|
1951
|
+
status: z.enum(RISK_STATUSES).optional().describe('Default: open. "closed" en vez de borrar -- nunca se borra un riesgo.'),
|
|
1952
|
+
ownerUid: z.string().optional().describe('Dueño del riesgo -- le avisa solo a esa persona al asignarla (ver list de miembros del workspace)'),
|
|
1953
|
+
moduleId: z.string().optional().describe('Módulo relacionado, si aplica (ver list_modules)'),
|
|
1954
|
+
})
|
|
1955
|
+
|
|
1956
|
+
registerUpdate('update_risk', '/v1/risks', 'Edita un riesgo existente -- típicamente para actualizar su mitigación o cerrarlo (status: "closed") cuando deja de aplicar.', {
|
|
1957
|
+
title: z.string().optional(),
|
|
1958
|
+
description: z.string().optional(),
|
|
1959
|
+
probability: z.enum(RISK_LEVELS).optional(),
|
|
1960
|
+
impact: z.enum(RISK_LEVELS).optional(),
|
|
1961
|
+
mitigation: z.string().optional(),
|
|
1962
|
+
status: z.enum(RISK_STATUSES).optional(),
|
|
1963
|
+
ownerUid: z.string().optional(),
|
|
1964
|
+
moduleId: z.string().optional(),
|
|
1965
|
+
})
|
|
1966
|
+
|
|
1766
1967
|
registerRead('list_content', '/v1/content',
|
|
1767
1968
|
'Lista las publicaciones de contenido (posts programados/publicados) de un proyecto.',
|
|
1768
1969
|
{}, (args, projectId) => ({ projectId }))
|