orquestra-mcp 1.26.0 → 1.29.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/package.json +3 -3
- package/src/server.mjs +173 -16
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.29.0",
|
|
4
|
+
"description": "Servidor MCP de Orquestra -- 115 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"
|
|
@@ -26,6 +26,6 @@
|
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
29
|
-
"zod": "^
|
|
29
|
+
"zod": "^4.6.5"
|
|
30
30
|
}
|
|
31
31
|
}
|
package/src/server.mjs
CHANGED
|
@@ -46,13 +46,15 @@ const PLATFORM_TYPES = ['android', 'ios', 'web', 'desktop-mac', 'desktop-win', '
|
|
|
46
46
|
const INFRA_STATUSES = ['operational', 'degraded', 'down']
|
|
47
47
|
const STAGE_IDS = STAGES.map(s => s.id)
|
|
48
48
|
|
|
49
|
-
// Adjuntos -- entityType en la tool es singular (task/note/idea/doc,
|
|
50
|
-
// natural para el modelo), la API usa el plural de la colección de
|
|
51
|
-
// Firestore (tasks/notes/ideas/docs) -- este mapa traduce entre los dos.
|
|
52
|
-
const ATTACHMENT_ENTITY_PATH = { task: 'tasks', note: 'notes', idea: 'ideas', doc: 'docs' }
|
|
49
|
+
// Adjuntos -- entityType en la tool es singular (task/note/idea/doc/content,
|
|
50
|
+
// más natural para el modelo), la API usa el plural de la colección de
|
|
51
|
+
// Firestore (tasks/notes/ideas/docs/content) -- este mapa traduce entre los dos.
|
|
52
|
+
const ATTACHMENT_ENTITY_PATH = { task: 'tasks', note: 'notes', idea: 'ideas', doc: 'docs', content: 'content' }
|
|
53
53
|
const ATTACHMENT_ENTITY_TYPES = Object.keys(ATTACHMENT_ENTITY_PATH)
|
|
54
54
|
const ATTACHMENT_IMAGE_MIME_BY_EXT = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
|
|
55
|
+
const ATTACHMENT_VIDEO_MIME_BY_EXT = { mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm' }
|
|
55
56
|
const MAX_ATTACHMENT_IMAGE_BYTES = 15 * 1024 * 1024
|
|
57
|
+
const MAX_ATTACHMENT_VIDEO_BYTES = 500 * 1024 * 1024
|
|
56
58
|
const MAX_ATTACHMENT_CODE_CHARS = 200_000
|
|
57
59
|
|
|
58
60
|
const SETUP_GUIDE = {
|
|
@@ -750,6 +752,84 @@ export function createServer() {
|
|
|
750
752
|
}
|
|
751
753
|
)
|
|
752
754
|
|
|
755
|
+
// Crear rama + abrir PR borrador desde una tarea (ORQ-204, 10 oct 2026):
|
|
756
|
+
// requiere que la GitHub App de ese repo tenga permisos de escritura en
|
|
757
|
+
// Contents/Pull requests -- si no, el servidor responde 502/403 con el
|
|
758
|
+
// detalle de GitHub. "Crear rama" guarda el nombre en task.branch (lo que
|
|
759
|
+
// ya lee sync_task_from_github); "Abrir PR" exige que la tarea ya tenga
|
|
760
|
+
// branch (de este tool o puesto a mano).
|
|
761
|
+
server.registerTool(
|
|
762
|
+
'create_task_branch',
|
|
763
|
+
{
|
|
764
|
+
description: 'Crea una rama en GitHub para esta tarea (ej. "ORQ-123-arregla-el-login") desde la rama base de la integración, y la guarda en task.branch. Idempotente: si la rama ya existe, no falla. Si el proyecto tiene varios repos conectados, pasa integrationId (list_github_integrations).',
|
|
765
|
+
inputSchema: {
|
|
766
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
767
|
+
taskId: z.string().describe('ID de la tarea'),
|
|
768
|
+
integrationId: z.string().optional().describe('Integración (repo) concreta; opcional si solo hay una'),
|
|
769
|
+
branchName: z.string().optional().describe('Nombre de rama explícito; por default "<shortCode>-<slug del título>"'),
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
async ({ projectId, taskId, integrationId, branchName }) => {
|
|
773
|
+
try {
|
|
774
|
+
return textResult(await apiPost(config, `/v1/tasks/${taskId}/github-branch`, { projectId: resolveProjectId(projectId), ...(integrationId ? { integrationId } : {}), ...(branchName ? { branchName } : {}) }))
|
|
775
|
+
} catch (err) {
|
|
776
|
+
return errorResult(err)
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
server.registerTool(
|
|
782
|
+
'open_task_pr',
|
|
783
|
+
{
|
|
784
|
+
description: 'Abre un PR borrador en GitHub desde task.branch hacia la rama base de la integración, con el shortCode y el link a la tarea en el título/cuerpo, y guarda prNumber/prUrl en la tarea. Requiere que la tarea ya tenga branch (create_task_branch primero, o uno puesto a mano). Idempotente: si ya hay un PR abierto para esa rama, devuelve ESE en vez de fallar.',
|
|
785
|
+
inputSchema: {
|
|
786
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
787
|
+
taskId: z.string().describe('ID de la tarea'),
|
|
788
|
+
integrationId: z.string().optional().describe('Integración (repo) concreta; opcional si solo hay una'),
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
async ({ projectId, taskId, integrationId }) => {
|
|
792
|
+
try {
|
|
793
|
+
return textResult(await apiPost(config, `/v1/tasks/${taskId}/github-pr`, { projectId: resolveProjectId(projectId), ...(integrationId ? { integrationId } : {}) }))
|
|
794
|
+
} catch (err) {
|
|
795
|
+
return errorResult(err)
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
// Sync de Issues de GitHub <-> tareas (ORQ-203, 10 oct 2026): opt-in POR
|
|
801
|
+
// INTEGRACIÓN (apagado por default). Prenderlo exige issuesModuleId (a
|
|
802
|
+
// qué módulo van las tareas que crea un issue nuevo) -- sin eso, create_task
|
|
803
|
+
// no puede cumplir con "toda tarea requiere módulo". Una vez prendido:
|
|
804
|
+
// issues.opened crea una tarea tipo bug con el link del issue; cerrar la
|
|
805
|
+
// tarea cierra el issue y cerrar el issue cierra la tarea (sin bucles,
|
|
806
|
+
// ambos lados son idempotentes). mirrorComments (opcional, aparte) espeja
|
|
807
|
+
// comentarios nuevos del issue como comentario de sistema en la tarea --
|
|
808
|
+
// solo en ese sentido, comentar en Orquestra no escribe de vuelta en GitHub.
|
|
809
|
+
server.registerTool(
|
|
810
|
+
'update_github_issues_sync',
|
|
811
|
+
{
|
|
812
|
+
description: 'Prende/apaga el sync de Issues<->tareas de un repo conectado (opt-in por integración). issuesSync:true exige issuesModuleId (módulo donde caen las tareas que crea un issue nuevo). mirrorComments es opcional y aparte: espeja comentarios nuevos del issue como comentario de sistema en la tarea (solo en ese sentido).',
|
|
813
|
+
inputSchema: {
|
|
814
|
+
integrationId: z.string().describe('Integración (repo conectado) a configurar -- list_github_integrations'),
|
|
815
|
+
issuesSync: z.boolean().optional().describe('Prender/apagar la creación automática de tareas desde issues y el cierre cruzado'),
|
|
816
|
+
issuesModuleId: z.string().optional().describe('Módulo donde crear las tareas que originan los issues -- obligatorio para prender issuesSync'),
|
|
817
|
+
mirrorComments: z.boolean().optional().describe('Espejar comentarios nuevos del issue como comentario de sistema en la tarea (GitHub -> Orquestra, no al revés)'),
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
async ({ integrationId, issuesSync, issuesModuleId, mirrorComments }) => {
|
|
821
|
+
try {
|
|
822
|
+
return textResult(await apiPatchPath(config, `/v1/github-integrations/${integrationId}/github-issues`, {
|
|
823
|
+
...(issuesSync !== undefined ? { issuesSync } : {}),
|
|
824
|
+
...(issuesModuleId !== undefined ? { issuesModuleId } : {}),
|
|
825
|
+
...(mirrorComments !== undefined ? { mirrorComments } : {}),
|
|
826
|
+
}))
|
|
827
|
+
} catch (err) {
|
|
828
|
+
return errorResult(err)
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
)
|
|
832
|
+
|
|
753
833
|
server.registerTool(
|
|
754
834
|
'run_closure_scan',
|
|
755
835
|
{
|
|
@@ -924,7 +1004,7 @@ export function createServer() {
|
|
|
924
1004
|
server.registerTool(
|
|
925
1005
|
'publish_content_postproxy',
|
|
926
1006
|
{
|
|
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). 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.',
|
|
1007
|
+
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.',
|
|
928
1008
|
inputSchema: {
|
|
929
1009
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
930
1010
|
contentId: z.string().describe('Pieza de Contenido a publicar (ver list_content)'),
|
|
@@ -941,6 +1021,79 @@ export function createServer() {
|
|
|
941
1021
|
}
|
|
942
1022
|
)
|
|
943
1023
|
|
|
1024
|
+
// sync_content_postproxy / reschedule_content_postproxy (10 oct 2026, a
|
|
1025
|
+
// pedido de José: "todo esto que estamos haciendo en web, también se
|
|
1026
|
+
// puede hacer por MCP?") -- paridad con los botones "Consultar ahora" y
|
|
1027
|
+
// "Reagendar en Postproxy" de ContentDetail.jsx, que hasta ahora solo
|
|
1028
|
+
// existían en la web. Mismo criterio que publish_content_postproxy
|
|
1029
|
+
// (server.registerTool directo, no CRUD estándar).
|
|
1030
|
+
server.registerTool(
|
|
1031
|
+
'sync_content_postproxy',
|
|
1032
|
+
{
|
|
1033
|
+
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.',
|
|
1034
|
+
inputSchema: {
|
|
1035
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
1036
|
+
contentId: z.string().describe('Pieza de Contenido a consultar (ver list_content)'),
|
|
1037
|
+
},
|
|
1038
|
+
},
|
|
1039
|
+
async ({ projectId, contentId }) => {
|
|
1040
|
+
try {
|
|
1041
|
+
const data = await apiPost(config, `/v1/content/${contentId}/sync-postproxy`, { projectId: resolveProjectId(projectId) })
|
|
1042
|
+
return textResult(data)
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
return errorResult(err)
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
server.registerTool(
|
|
1050
|
+
'reschedule_content_postproxy',
|
|
1051
|
+
{
|
|
1052
|
+
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.',
|
|
1053
|
+
inputSchema: {
|
|
1054
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
1055
|
+
contentId: z.string().describe('Pieza de Contenido a reagendar (ver list_content)'),
|
|
1056
|
+
channelId: z.string().describe('Canal ya enviado a Postproxy (con externalGuid) dentro de esa pieza'),
|
|
1057
|
+
scheduledAt: z.number().describe('Nuevo timestamp en ms de cuándo debe publicarse'),
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
async ({ projectId, contentId, channelId, scheduledAt }) => {
|
|
1061
|
+
try {
|
|
1062
|
+
const data = await apiPost(config, `/v1/content/${contentId}/reschedule-postproxy`, { projectId: resolveProjectId(projectId), channelId, scheduledAt })
|
|
1063
|
+
return textResult(data)
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
return errorResult(err)
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
// backfill_channel_content (10 oct 2026, a pedido de José: "agregar canal
|
|
1071
|
+
// y agregar los contenidos que aún no se publican... para que todos
|
|
1072
|
+
// estén parejos") -- recorre TODO el proyecto en un solo llamado, no una
|
|
1073
|
+
// pieza a la vez (equivalente en bulk al botón "Copiar contenido acá" de
|
|
1074
|
+
// ContentDetail.jsx en la web, que solo opera sobre una pieza). No es CRUD
|
|
1075
|
+
// estándar (no crea ni edita UNA entidad con un id propio), así que va
|
|
1076
|
+
// con server.registerTool directo, mismo criterio que publish_content_postproxy.
|
|
1077
|
+
server.registerTool(
|
|
1078
|
+
'backfill_channel_content',
|
|
1079
|
+
{
|
|
1080
|
+
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.',
|
|
1081
|
+
inputSchema: {
|
|
1082
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
1083
|
+
channelId: z.string().describe('Canal a agregar donde falte (ver list_channels) -- recién conectado o con pocas piezas todavía'),
|
|
1084
|
+
sourceChannelId: z.string().describe('Canal del que copiar el contenido en cada pieza (ver list_channels) -- tiene que ser distinto de channelId'),
|
|
1085
|
+
},
|
|
1086
|
+
},
|
|
1087
|
+
async ({ projectId, channelId, sourceChannelId }) => {
|
|
1088
|
+
try {
|
|
1089
|
+
const data = await apiPost(config, '/v1/content/backfill-channel', { projectId: resolveProjectId(projectId), channelId, sourceChannelId })
|
|
1090
|
+
return textResult(data)
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
return errorResult(err)
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
)
|
|
1096
|
+
|
|
944
1097
|
// Checklist de una tarea -- registradas directo con server.registerTool
|
|
945
1098
|
// (no con registerCreate/registerUpdate) porque su ruta necesita DOS ids
|
|
946
1099
|
// dinámicos (taskId + itemId del checklist), y esos helpers solo arman
|
|
@@ -1043,11 +1196,11 @@ export function createServer() {
|
|
|
1043
1196
|
server.registerTool(
|
|
1044
1197
|
'list_attachments',
|
|
1045
1198
|
{
|
|
1046
|
-
description: 'Lista los adjuntos (imágenes + código) de una task/note/idea/doc de Orquestra.',
|
|
1199
|
+
description: 'Lista los adjuntos (imágenes + video + código) de una task/note/idea/doc/content de Orquestra.',
|
|
1047
1200
|
inputSchema: {
|
|
1048
1201
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
1049
1202
|
entityType: z.enum(ATTACHMENT_ENTITY_TYPES).describe('Tipo de entidad dueña de los adjuntos'),
|
|
1050
|
-
entityId: z.string().describe('ID de la entidad (task/note/idea/doc)'),
|
|
1203
|
+
entityId: z.string().describe('ID de la entidad (task/note/idea/doc/content)'),
|
|
1051
1204
|
},
|
|
1052
1205
|
},
|
|
1053
1206
|
async ({ projectId, entityType, entityId }) => {
|
|
@@ -1071,12 +1224,12 @@ export function createServer() {
|
|
|
1071
1224
|
server.registerTool(
|
|
1072
1225
|
'add_attachment',
|
|
1073
1226
|
{
|
|
1074
|
-
description: 'Adjunta evidencia (una imagen o un pedazo de código) a una task/note/idea/doc de Orquestra. Manda EXACTAMENTE uno de filePath (ruta local -- una imagen png/jpg/jpeg/gif/webp se sube a S3, cualquier otra extensión se lee como texto/código) o content (texto/código directo, sin tocar el filesystem). Nunca mandes bytes de imagen en base64 dentro de content -- usa filePath para que este server los suba él mismo.',
|
|
1227
|
+
description: 'Adjunta evidencia (una imagen, un video o un pedazo de código) a una task/note/idea/doc/content de Orquestra. Manda EXACTAMENTE uno de filePath (ruta local -- una imagen png/jpg/jpeg/gif/webp o un video mp4/mov/webm se sube a S3, cualquier otra extensión se lee como texto/código) o content (texto/código directo, sin tocar el filesystem). Nunca mandes bytes de imagen/video en base64 dentro de content -- usa filePath para que este server los suba él mismo.',
|
|
1075
1228
|
inputSchema: {
|
|
1076
1229
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
1077
1230
|
entityType: z.enum(ATTACHMENT_ENTITY_TYPES).describe('Tipo de entidad a la que se adjunta'),
|
|
1078
|
-
entityId: z.string().describe('ID de la entidad (task/note/idea/doc)'),
|
|
1079
|
-
filePath: z.string().optional().describe('Ruta local a un archivo -- imagen (png/jpg/jpeg/gif/webp) o código/texto.'),
|
|
1231
|
+
entityId: z.string().describe('ID de la entidad (task/note/idea/doc/content)'),
|
|
1232
|
+
filePath: z.string().optional().describe('Ruta local a un archivo -- imagen (png/jpg/jpeg/gif/webp), video (mp4/mov/webm) o código/texto.'),
|
|
1080
1233
|
content: z.string().optional().describe('Texto/código a adjuntar directo, sin leer un archivo.'),
|
|
1081
1234
|
language: z.string().optional().describe('Label libre del lenguaje del snippet (ej. "python", "diff") -- solo informativo, no tokeniza (Orquestra no resalta sintaxis).'),
|
|
1082
1235
|
fileName: z.string().optional().describe('Nombre a mostrar -- si no se manda, sale de filePath o de un default genérico.'),
|
|
@@ -1099,11 +1252,15 @@ export function createServer() {
|
|
|
1099
1252
|
} else {
|
|
1100
1253
|
const stat = statSync(filePath)
|
|
1101
1254
|
const ext = path.extname(filePath).slice(1).toLowerCase()
|
|
1102
|
-
const
|
|
1255
|
+
const imageMime = ATTACHMENT_IMAGE_MIME_BY_EXT[ext]
|
|
1256
|
+
const videoMime = ATTACHMENT_VIDEO_MIME_BY_EXT[ext]
|
|
1257
|
+
const mime = imageMime || videoMime
|
|
1258
|
+
const kind = imageMime ? 'image' : videoMime ? 'video' : null
|
|
1103
1259
|
const resolvedFileName = fileName || path.basename(filePath)
|
|
1104
1260
|
if (mime) {
|
|
1105
|
-
|
|
1106
|
-
|
|
1261
|
+
const maxBytes = kind === 'video' ? MAX_ATTACHMENT_VIDEO_BYTES : MAX_ATTACHMENT_IMAGE_BYTES
|
|
1262
|
+
if (stat.size > maxBytes) {
|
|
1263
|
+
throw new Error(`El archivo supera el máximo de ${maxBytes / 1024 / 1024}MB`)
|
|
1107
1264
|
}
|
|
1108
1265
|
const buffer = readFileSync(filePath)
|
|
1109
1266
|
const { uploadUrl, key } = await apiPost(config, '/v1/attachments/upload-url', {
|
|
@@ -1111,7 +1268,7 @@ export function createServer() {
|
|
|
1111
1268
|
fileName: resolvedFileName, fileType: mime, fileSize: stat.size,
|
|
1112
1269
|
})
|
|
1113
1270
|
await putToPresignedUrl(uploadUrl, buffer, mime)
|
|
1114
|
-
body = { kind
|
|
1271
|
+
body = { kind, key, fileName: resolvedFileName, fileType: mime, fileSize: stat.size, caption }
|
|
1115
1272
|
} else {
|
|
1116
1273
|
const text = readFileSync(filePath, 'utf8')
|
|
1117
1274
|
if (text.length > MAX_ATTACHMENT_CODE_CHARS) {
|
|
@@ -1138,11 +1295,11 @@ export function createServer() {
|
|
|
1138
1295
|
server.registerTool(
|
|
1139
1296
|
'delete_attachment',
|
|
1140
1297
|
{
|
|
1141
|
-
description: 'Borra un adjunto (imagen o código) de una task/note/idea/doc de Orquestra -- limpia también el objeto en S3 si era
|
|
1298
|
+
description: 'Borra un adjunto (imagen, video o código) de una task/note/idea/doc/content de Orquestra -- limpia también el objeto en S3 si era imagen o video. Segunda excepción a "nunca borrado" (junto a delete_checklist_item) -- usalo para limpiar evidencia subida por error, no como reemplazo de mantener historial.',
|
|
1142
1299
|
inputSchema: {
|
|
1143
1300
|
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
1144
1301
|
entityType: z.enum(ATTACHMENT_ENTITY_TYPES).describe('Tipo de entidad dueña del adjunto'),
|
|
1145
|
-
entityId: z.string().describe('ID de la entidad (task/note/idea/doc)'),
|
|
1302
|
+
entityId: z.string().describe('ID de la entidad (task/note/idea/doc/content)'),
|
|
1146
1303
|
attachmentId: z.string().describe('ID del adjunto a borrar (ver list_attachments)'),
|
|
1147
1304
|
},
|
|
1148
1305
|
},
|