orquestra-mcp 1.40.0 → 1.43.1
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 +1 -1
- package/src/api.mjs +26 -56
- package/src/http.mjs +102 -0
- package/src/server.mjs +87 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orquestra-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.1",
|
|
4
4
|
"description": "Servidor MCP de Orquestra -- 135 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": {
|
package/src/api.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readConfigFile, resolveProjectForCwd, CONFIG_PATH } from './configFile.mjs'
|
|
2
|
+
import { request } from './http.mjs'
|
|
2
3
|
|
|
3
4
|
const DEFAULT_API_BASE = 'https://api.orquestra.me'
|
|
4
5
|
|
|
@@ -38,12 +39,7 @@ export function loadConfig() {
|
|
|
38
39
|
return { apiBase, apiToken, workspaceId, defaultProjectId }
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
export
|
|
42
|
-
constructor(status, message) {
|
|
43
|
-
super(message)
|
|
44
|
-
this.status = status
|
|
45
|
-
}
|
|
46
|
-
}
|
|
42
|
+
export { ApiError } from './http.mjs'
|
|
47
43
|
|
|
48
44
|
function buildUrl(config, path, params) {
|
|
49
45
|
const url = new URL(path, config.apiBase)
|
|
@@ -54,57 +50,29 @@ function buildUrl(config, path, params) {
|
|
|
54
50
|
return url
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (!res.ok) {
|
|
63
|
-
const message = (body && body.error) || (typeof body === 'string' ? body : `HTTP ${res.status}`)
|
|
64
|
-
throw new ApiError(res.status, message)
|
|
65
|
-
}
|
|
66
|
-
return body
|
|
67
|
-
}
|
|
53
|
+
// Todas pasan por request() (http.mjs, ORQ-343): plazo por intento, cancelación de la tool y
|
|
54
|
+
// reintentos acotados -- las lecturas ante fallas transitorias, las escrituras solo ante el
|
|
55
|
+
// throttling del API Gateway (no llegó a la Lambda, no puede duplicar nada).
|
|
56
|
+
const auth = config => ({ Authorization: `Bearer ${config.apiToken}` })
|
|
57
|
+
const jsonHeaders = config => ({ ...auth(config), 'Content-Type': 'application/json' })
|
|
68
58
|
|
|
69
59
|
export async function apiGet(config, path, params = {}) {
|
|
70
|
-
|
|
71
|
-
const res = await fetch(url, {
|
|
72
|
-
headers: { Authorization: `Bearer ${config.apiToken}` },
|
|
73
|
-
})
|
|
74
|
-
return parseResponse(res)
|
|
60
|
+
return request(buildUrl(config, path, params), { headers: auth(config) })
|
|
75
61
|
}
|
|
76
62
|
|
|
77
63
|
export async function apiPost(config, path, { projectId, ...body }) {
|
|
78
|
-
|
|
79
|
-
const res = await fetch(url, {
|
|
80
|
-
method: 'POST',
|
|
81
|
-
headers: { Authorization: `Bearer ${config.apiToken}`, 'Content-Type': 'application/json' },
|
|
82
|
-
body: JSON.stringify(body),
|
|
83
|
-
})
|
|
84
|
-
return parseResponse(res)
|
|
64
|
+
return request(buildUrl(config, path, { projectId }), { method: 'POST', headers: jsonHeaders(config), body: JSON.stringify(body) })
|
|
85
65
|
}
|
|
86
66
|
|
|
87
67
|
export async function apiPatch(config, path, { projectId, id, ...body }) {
|
|
88
|
-
|
|
89
|
-
const res = await fetch(url, {
|
|
90
|
-
method: 'PATCH',
|
|
91
|
-
headers: { Authorization: `Bearer ${config.apiToken}`, 'Content-Type': 'application/json' },
|
|
92
|
-
body: JSON.stringify(body),
|
|
93
|
-
})
|
|
94
|
-
return parseResponse(res)
|
|
68
|
+
return request(buildUrl(config, `${path}/${id}`, { projectId }), { method: 'PATCH', headers: jsonHeaders(config), body: JSON.stringify(body) })
|
|
95
69
|
}
|
|
96
70
|
|
|
97
71
|
// Mismo método que apiPatch, pero para un doc singleton SIN id en la ruta
|
|
98
72
|
// (ej. /v1/content-config) -- set_hourly_rate (8 oct 2026, ORQ-247) es el
|
|
99
73
|
// primer caso real.
|
|
100
74
|
export async function apiPatchPath(config, path, { projectId, ...body }) {
|
|
101
|
-
|
|
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)
|
|
75
|
+
return request(buildUrl(config, path, { projectId }), { method: 'PATCH', headers: jsonHeaders(config), body: JSON.stringify(body) })
|
|
108
76
|
}
|
|
109
77
|
|
|
110
78
|
// Único método DELETE del cliente (21 ago 2026, para delete_checklist_item
|
|
@@ -112,23 +80,25 @@ export async function apiPatchPath(config, path, { projectId, ...body }) {
|
|
|
112
80
|
// apiPatch (arma la URL como `${path}/${id}`), sin body -- ningún delete de
|
|
113
81
|
// esta API necesita mandar datos, solo identificar el recurso.
|
|
114
82
|
export async function apiDelete(config, path, { projectId, id }) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
83
|
+
return request(buildUrl(config, `${path}/${id}`, { projectId }), { method: 'DELETE', headers: auth(config) })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// DELETE sobre un recurso singleton SIN id en la ruta (ej. /v1/client-portal,
|
|
87
|
+
// ORQ-332) -- mismo criterio que apiPatchPath.
|
|
88
|
+
export async function apiDeletePath(config, path, { projectId }) {
|
|
89
|
+
return request(buildUrl(config, path, { projectId }), { method: 'DELETE', headers: auth(config) })
|
|
121
90
|
}
|
|
122
91
|
|
|
123
92
|
// PUT crudo a una presigned URL de S3 (createAttachmentUploadUrl en
|
|
124
93
|
// orquestra-infra) -- a diferencia de apiGet/apiPost/etc, este request NO
|
|
125
94
|
// va contra config.apiBase ni lleva el Bearer del MCP: la URL firmada YA
|
|
126
|
-
// es la autorización completa.
|
|
95
|
+
// es la autorización completa. Plazo más largo (sube bytes); un PUT a la
|
|
96
|
+
// misma key es idempotente, pero igual no se reintenta para no duplicar
|
|
97
|
+
// tiempos largos de subida.
|
|
127
98
|
export async function putToPresignedUrl(uploadUrl, buffer, contentType) {
|
|
128
|
-
|
|
129
|
-
method: 'PUT',
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
if (!res.ok) throw new Error(`Falló la subida a S3: HTTP ${res.status}`)
|
|
99
|
+
try {
|
|
100
|
+
await request(uploadUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, body: buffer }, { timeoutMs: 120_000, retries: 0 })
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw new Error(`Falló la subida a S3: ${err.status ? `HTTP ${err.status}` : err.message}`)
|
|
103
|
+
}
|
|
134
104
|
}
|
package/src/http.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// fetch con plazo, cancelación y reintentos acotados (ORQ-343). Antes cada llamada era un fetch
|
|
2
|
+
// sin signal: una API colgada dejaba la tool esperando para siempre y un 503 pasajero se le
|
|
3
|
+
// devolvía al agente como error definitivo.
|
|
4
|
+
//
|
|
5
|
+
// Reglas de reintento (máx. RETRIES extra, espera progresiva con jitter, respeta Retry-After):
|
|
6
|
+
// - Lecturas (GET): error de red, plazo vencido, 429 de throttling, 502/503/504.
|
|
7
|
+
// - Escrituras (POST/PATCH/DELETE): SOLO el 429 de throttling del API Gateway, que garantiza que
|
|
8
|
+
// la petición no llegó a la Lambda. Un plazo vencido o un 5xx en una escritura NO se reintenta:
|
|
9
|
+
// la entidad pudo crearse y reintentar la duplicaría (la API no tiene idempotencia general).
|
|
10
|
+
// - El 429 de la propia API (créditos agotados, tope de regeneraciones...) trae `error` en el
|
|
11
|
+
// body y es definitivo: no se reintenta.
|
|
12
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
13
|
+
|
|
14
|
+
export const TIMEOUT_MS = 30_000
|
|
15
|
+
export const RETRIES = 2
|
|
16
|
+
const BASE_DELAY_MS = 500
|
|
17
|
+
const MAX_DELAY_MS = 5_000
|
|
18
|
+
const RETRY_STATUS_READ = new Set([502, 503, 504])
|
|
19
|
+
|
|
20
|
+
// Signal de cancelación de la tool en curso (lo pone server.mjs por llamada) -- así no hay que
|
|
21
|
+
// pasarlo a mano por las ~140 tools.
|
|
22
|
+
export const cancelContext = new AsyncLocalStorage()
|
|
23
|
+
|
|
24
|
+
export class ApiError extends Error {
|
|
25
|
+
constructor(status, message, { code } = {}) {
|
|
26
|
+
super(message)
|
|
27
|
+
this.status = status
|
|
28
|
+
if (code) this.code = code
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
33
|
+
if (signal?.aborted) return reject(signal.reason)
|
|
34
|
+
const t = setTimeout(resolve, ms)
|
|
35
|
+
signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason) }, { once: true })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// AbortSignal.any no existe en Node 18 (engines >=18).
|
|
39
|
+
function anySignal(signals) {
|
|
40
|
+
const ctrl = new AbortController()
|
|
41
|
+
for (const s of signals.filter(Boolean)) {
|
|
42
|
+
if (s.aborted) { ctrl.abort(s.reason); break }
|
|
43
|
+
s.addEventListener('abort', () => ctrl.abort(s.reason), { once: true })
|
|
44
|
+
}
|
|
45
|
+
return ctrl.signal
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function backoff(attempt, retryAfter) {
|
|
49
|
+
const secs = Number(retryAfter)
|
|
50
|
+
if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1000, MAX_DELAY_MS)
|
|
51
|
+
return Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS) * (0.75 + Math.random() * 0.5)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readBody(res) {
|
|
55
|
+
const text = await res.text()
|
|
56
|
+
try { return text ? JSON.parse(text) : null } catch { return text }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isGatewayThrottle = (status, body) => status === 429 && !(body && typeof body === 'object' && body.error)
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hace la petición y devuelve el body parseado (o lanza ApiError). `timeoutMs` es por intento.
|
|
63
|
+
* `signal` (opcional) se suma al de la tool en curso.
|
|
64
|
+
*/
|
|
65
|
+
export async function request(url, init = {}, { timeoutMs = TIMEOUT_MS, retries = RETRIES, signal } = {}) {
|
|
66
|
+
const method = (init.method || 'GET').toUpperCase()
|
|
67
|
+
const isRead = method === 'GET' || method === 'HEAD'
|
|
68
|
+
const cancel = anySignal([signal, cancelContext.getStore()?.signal])
|
|
69
|
+
|
|
70
|
+
for (let attempt = 0; ; attempt++) {
|
|
71
|
+
const last = attempt >= retries
|
|
72
|
+
let res
|
|
73
|
+
try {
|
|
74
|
+
res = await fetch(url, { ...init, signal: anySignal([cancel, AbortSignal.timeout(timeoutMs)]) })
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (cancel.aborted) throw new ApiError(0, 'Cancelado', { code: 'cancelled' })
|
|
77
|
+
const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError'
|
|
78
|
+
if (isRead && !last) { await sleep(backoff(attempt), cancel); continue }
|
|
79
|
+
if (timedOut) {
|
|
80
|
+
throw new ApiError(0, isRead
|
|
81
|
+
? `La API no respondió en ${timeoutMs / 1000} s (${retries + 1} intentos)`
|
|
82
|
+
: `La API no respondió en ${timeoutMs / 1000} s. La escritura pudo haberse aplicado: revisa con la tool list_*/get_* correspondiente antes de reintentar, para no duplicarla.`,
|
|
83
|
+
{ code: 'timeout' })
|
|
84
|
+
}
|
|
85
|
+
throw new ApiError(0, isRead
|
|
86
|
+
? `No se pudo conectar con la API: ${err?.cause?.code || err?.message}`
|
|
87
|
+
: `Se perdió la conexión con la API (${err?.cause?.code || err?.message}). La escritura pudo haberse aplicado: revisa antes de reintentar.`,
|
|
88
|
+
{ code: 'network' })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const body = await readBody(res)
|
|
92
|
+
if (res.ok) return body
|
|
93
|
+
|
|
94
|
+
const retryable = isGatewayThrottle(res.status, body) || (isRead && RETRY_STATUS_READ.has(res.status))
|
|
95
|
+
if (retryable && !last) {
|
|
96
|
+
await sleep(backoff(attempt, res.headers.get('retry-after')), cancel)
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
const message = (body && body.error) || (body && body.message) || (typeof body === 'string' && body) || `HTTP ${res.status}`
|
|
100
|
+
throw new ApiError(res.status, message)
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -3,7 +3,8 @@ 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, apiPatchPath, apiDelete, putToPresignedUrl, ApiError } from './api.mjs'
|
|
6
|
+
import { loadConfig, apiGet, apiPost, apiPatch, apiPatchPath, apiDelete, apiDeletePath, putToPresignedUrl, ApiError } from './api.mjs'
|
|
7
|
+
import { cancelContext } from './http.mjs'
|
|
7
8
|
import { STAGES, PLAYBOOKS } from './stages.mjs'
|
|
8
9
|
import { FOUNDATIONS } from './foundations.mjs'
|
|
9
10
|
import { PLATFORM_PLAYBOOKS, TRACKING_TOOLS } from './platformPlaybooks.mjs'
|
|
@@ -31,8 +32,14 @@ const TASK_TYPES = ['feature', 'bug', 'improvement', 'tech', 'docs', 'idea', 'de
|
|
|
31
32
|
const ACTIVITY_TYPES = ['meeting', 'call', 'followup']
|
|
32
33
|
const ACTIVITY_STATUSES = ['scheduled', 'done', 'cancelled']
|
|
33
34
|
const TASK_PRIORITIES = ['high', 'medium', 'low']
|
|
34
|
-
// Relaciones (ORQ-326) -- misma lista que orquestra-infra/schema/enums/RelationItemType.json.
|
|
35
|
-
const RELATION_TYPES = ['task', 'activity', 'note', 'doc', 'idea']
|
|
35
|
+
// Relaciones (ORQ-326, ORQ-347) -- misma lista que orquestra-infra/schema/enums/RelationItemType.json.
|
|
36
|
+
const RELATION_TYPES = ['task', 'activity', 'note', 'doc', 'idea', 'risk', 'content', 'changelog', 'distribution', 'service']
|
|
37
|
+
// `relatedTo` en los create_* (ORQ-347): de dónde sale lo que se crea. El server valida el destino
|
|
38
|
+
// antes de crear y deja el vínculo hecho (lo ve list_relations desde los dos lados).
|
|
39
|
+
const RELATED_TO = z.object({
|
|
40
|
+
type: z.enum(RELATION_TYPES).describe('"task" o "activity" (reunión/llamada/seguimiento) normalmente'),
|
|
41
|
+
id: z.string().describe('id del ítem de origen'),
|
|
42
|
+
}).optional().describe('De dónde sale esto (ej. el riesgo/nota/reunión/idea que surgió trabajando en una tarea: {type:"task", id}). Queda vinculado en "Relacionado" de los dos lados -- úsalo en vez de escribir "sale de ORQ-123" en el texto.')
|
|
36
43
|
// Verificaciones por plataforma de una tarea (ORQ-319) -- ver
|
|
37
44
|
// orquestra-infra/functions/api/lib/verifications.mjs para las reglas reales.
|
|
38
45
|
const VERIFICATION_PLATFORMS = ['web', 'ios', 'android', 'api', 'other']
|
|
@@ -115,7 +122,8 @@ Durante el trabajo normal de código, sin que el usuario te lo pida explícitame
|
|
|
115
122
|
- Todo cambio de statusId vía update_task valida la transición (nextIds) y queda en el historial de la tarea -- pero ese historial no dice nada por sí solo. Manda SIEMPRE el parámetro "comment" cuando cambies statusId, explicando qué hiciste o por qué -- sin esto, el usuario ve que la tarea cambió pero no sabe por qué ni qué se hizo de verdad (encontrado real: quedaba invisible, a diferencia de cuando un humano mueve la tarjeta a mano en el kanban).
|
|
116
123
|
- Conocimiento que sirve más allá de un proyecto (guías, procesos, decisiones transversales) va en la base del workspace: revisa list_knowledge antes de documentarlo de nuevo, créalo con create_knowledge o súbelo con promote_to_knowledge; lo de un solo proyecto sigue en notas/docs.
|
|
117
124
|
- Reuniones, llamadas y seguimientos NO son tareas: son actividades (no se entregan, se programan y se registran). Usa log_activity para agendarlas o registrarlas, update_activity para marcarlas hechas con su resultado (outcome) y siguiente paso (nextStep), y list_activities para ver la agenda/bitácora -- nunca create_task con esos tipos. Si del siguiente paso sale trabajo real, eso sí es una tarea (create_task normal, con módulo y fecha).
|
|
118
|
-
- Para dejar constancia de que una nota, doc, idea
|
|
125
|
+
- Para dejar constancia de que una nota, doc, idea, tarea, riesgo, pieza de contenido, entrada de changelog, distribución o servicio es DE otra cosa del proyecto -- "esta nota es de la reunión X", "esta tarea sale de este doc" --, vincúlalas con link_items en vez de meter la referencia como texto en la descripción/cuerpo; list_relations muestra todo lo relacionado de un ítem.
|
|
126
|
+
- Si mientras trabajas en una tarea creas algo que sale de ella (un riesgo que detectaste, una reunión, una nota, una idea, una entrada de changelog...), manda relatedTo: {type:"task", id} en el create_* para que quede ligado desde el principio. Si un paso del checklist depende de otra cosa (otra tarea, un doc, un riesgo), vincúlalo desde ese ítem con link_items + checklistItemId; un folio suelto en el texto del ítem ("requiere ORQ-75") el server lo vincula solo.
|
|
119
127
|
- Antes de crear un módulo/estatus/categoría/servicio/entorno/distribución, revisa con el list_* correspondiente que no exista ya uno con ese nombre.
|
|
120
128
|
- Si una nota que vas a crear (create_note) es claramente sobre un módulo o una tarea específica -- por ejemplo durante un setup guiado donde acabas de crear ambos -- mándale moduleId/taskId para ligarla, en vez de dejarla suelta. No inventes la relación si no es clara.
|
|
121
129
|
- create_changelog_entry SIEMPRE deja la entrada en Unreleased (nunca mandes version salvo que el usuario te haya dado un número real) -- cortar una versión es release_changelog_version, una acción DELIBERADA que solo se llama si el usuario la pide explícitamente ("cortá la versión", "hacé el release de X") o confirma un número/fecha real que vos propusiste. No la dispares solo porque terminaste una tarea o un lote de trabajo grande.
|
|
@@ -149,12 +157,23 @@ const SCOPE_EVIDENCE = {
|
|
|
149
157
|
doneSource: z.enum(['manual', 'github', 'sibling']).optional(),
|
|
150
158
|
commitSha: z.string().optional().describe('commit que respalda que ya está hecho en esa plataforma'),
|
|
151
159
|
commitUrl: z.string().optional(),
|
|
160
|
+
notApplicable: z.boolean().optional().describe('true = esta plataforma NO aplica para la tarea (cuenta como resuelta en paridad). Solo con el motivo y cuando el usuario lo decidió -- nunca para tapar trabajo pendiente.'),
|
|
161
|
+
notApplicableReason: z.string().optional().describe('Motivo de "no aplica" (obligatorio si notApplicable es true)'),
|
|
152
162
|
}
|
|
153
163
|
|
|
154
164
|
export function createServer() {
|
|
155
165
|
const config = loadConfig()
|
|
156
166
|
const server = new McpServer({ name: 'orquestra-mcp', version: PKG_VERSION }, { instructions: SERVER_INSTRUCTIONS })
|
|
157
167
|
|
|
168
|
+
// Cancelación (ORQ-343): el SDK pasa `extra.signal` (último argumento del handler) y lo aborta
|
|
169
|
+
// si el cliente cancela la tool. Se publica en cancelContext para que request() lo use sin
|
|
170
|
+
// tener que pasarlo a mano por cada tool.
|
|
171
|
+
const registerTool = server.registerTool.bind(server)
|
|
172
|
+
server.registerTool = (name, def, handler) => registerTool(name, def, (...args) => {
|
|
173
|
+
const extra = args[args.length - 1]
|
|
174
|
+
return cancelContext.run({ signal: extra?.signal }, () => handler(...args))
|
|
175
|
+
})
|
|
176
|
+
|
|
158
177
|
function resolveProjectId(projectId) {
|
|
159
178
|
if (config.defaultProjectId) return config.defaultProjectId
|
|
160
179
|
if (!projectId) {
|
|
@@ -258,7 +277,7 @@ export function createServer() {
|
|
|
258
277
|
registerRead(
|
|
259
278
|
'search',
|
|
260
279
|
'/v1/search',
|
|
261
|
-
'Busca por texto en tareas, actividades (reuniones/llamadas), notas, ideas y docs de un proyecto de Orquestra.
|
|
280
|
+
'Busca por texto en tareas, actividades (reuniones/llamadas), notas, ideas y docs de un proyecto de Orquestra. También por folio ("ORQ-123", la exacta sale primero) y sin importar acentos. Primero lo que coincide en el folio o el título, después en el cuerpo; tope de 30 resultados: si buscas ideas o docs en un proyecto con muchas tareas, pasa `types` para que no queden tapadas.',
|
|
262
281
|
{
|
|
263
282
|
q: z.string().describe('Texto a buscar'),
|
|
264
283
|
types: z.array(z.enum(['task', 'activity', 'note', 'idea', 'doc', 'knowledge'])).optional().describe('Limitar a estos tipos (opcional). Sin esto busca en todos. knowledge = solo la base vinculada a este proyecto.'),
|
|
@@ -625,6 +644,7 @@ export function createServer() {
|
|
|
625
644
|
'/v1/tasks',
|
|
626
645
|
'Crea una tarea en un proyecto de Orquestra. moduleId y dueDate son OBLIGATORIOS -- revisa list_modules primero (si ninguno aplica, creá uno con create_module antes de crear la tarea, nunca la dejes sin agrupar); dueDate se calcula con criterio real (prioridad/tamaño del trabajo), nunca un valor arbitrario. Una reunión, llamada o seguimiento NO es tarea: usa log_activity (type "business" sí es tarea: entregable no-código).',
|
|
627
646
|
{
|
|
647
|
+
relatedTo: RELATED_TO,
|
|
628
648
|
title: z.string().describe('Título de la tarea'),
|
|
629
649
|
description: z.string().optional(),
|
|
630
650
|
type: z.enum(TASK_TYPES).optional().describe('Default: feature'),
|
|
@@ -742,6 +762,7 @@ export function createServer() {
|
|
|
742
762
|
time: z.string().regex(/^\d{2}:\d{2}$/, 'Formato esperado: HH:MM').optional().describe('Hora local (HH:MM, 24 h), si se sabe.'),
|
|
743
763
|
activityStatus: z.enum(ACTIVITY_STATUSES).optional().describe('Default "scheduled" (programada). "done" para registrar una que ya pasó.'),
|
|
744
764
|
...ACTIVITY_FIELDS_SCHEMA,
|
|
765
|
+
relatedTo: RELATED_TO,
|
|
745
766
|
},
|
|
746
767
|
},
|
|
747
768
|
async ({ projectId, date, time, ...body }) => {
|
|
@@ -904,7 +925,7 @@ export function createServer() {
|
|
|
904
925
|
registerRead(
|
|
905
926
|
'list_relations',
|
|
906
927
|
'/v1/relations',
|
|
907
|
-
'Sección "Relacionado" de una tarea, actividad (reunión/llamada/seguimiento), nota, doc o
|
|
928
|
+
'Sección "Relacionado" de una tarea, actividad (reunión/llamada/seguimiento), nota, doc, idea, riesgo, contenido, entrada de changelog, distribución o servicio: junta los vínculos manuales (link_items, source "manual", con relationId para unlink_items; source "checklist" + checklistItemId si sale de un ítem del checklist de la tarea) Y los que ya salen de campos existentes (nota de la tarea/minuta de la reunión, nota relacionada, ADR publicado, tarea que proviene de una nota, siguiente paso de una reunión, tareas ligadas a una idea -- source = esa etiqueta, relationId null, no se pueden quitar desde acá). Sin duplicados (el manual gana) y sin extremos borrados. No incluye menciones [[...]] -- esas van en get_backlinks.',
|
|
908
929
|
{
|
|
909
930
|
type: z.enum(RELATION_TYPES).describe('"activity" para reunión/llamada/seguimiento, "task" para cualquier otra tarea -- el server rechaza el tipo equivocado'),
|
|
910
931
|
id: z.string().describe('id del ítem (de list_tasks/list_activities/list_notes/list_docs/list_ideas)'),
|
|
@@ -915,12 +936,14 @@ export function createServer() {
|
|
|
915
936
|
registerCreate(
|
|
916
937
|
'link_items',
|
|
917
938
|
'/v1/relations',
|
|
918
|
-
'Vincula a mano dos cosas del mismo proyecto (tarea, actividad, nota, doc o
|
|
939
|
+
'Vincula a mano dos cosas del mismo proyecto (tarea, actividad, nota, doc, idea, riesgo, contenido, entrada de changelog, distribución o servicio) -- ej. "esta nota es de la reunión X", "esta tarea sale de este doc", "este riesgo es del backend de prod". Con checklistItemId + taskId el vínculo sale de un ÍTEM del checklist de esa tarea (ej. "este paso requiere ORQ-75"): se ve bajo el ítem y se borra con él. El vínculo no tiene dirección (el orden a/b da igual, el server lo ordena) y es idempotente: vincular dos veces lo mismo no duplica (responde alreadyLinked). Saca los ids de list_tasks, list_activities, list_notes, list_docs, list_ideas, list_risks, list_content, list_changelog, list_distributions y list_services; una reunión/llamada/seguimiento es "activity", no "task" (el server valida el tipo real y que ambos existan). No se puede vincular algo consigo mismo. Úsalo en vez de escribir "ver reunión X" en el texto.',
|
|
919
940
|
{
|
|
920
941
|
aType: z.enum(RELATION_TYPES).describe('Tipo del primer ítem'),
|
|
921
942
|
aId: z.string().describe('id del primer ítem'),
|
|
922
943
|
bType: z.enum(RELATION_TYPES).describe('Tipo del segundo ítem'),
|
|
923
944
|
bId: z.string().describe('id del segundo ítem'),
|
|
945
|
+
checklistItemId: z.string().optional().describe('id del ítem del checklist (list_checklist_items) del que sale el vínculo -- requiere taskId'),
|
|
946
|
+
taskId: z.string().optional().describe('Con checklistItemId: la tarea dueña del ítem, que tiene que ser uno de los dos extremos (aType "task")'),
|
|
924
947
|
}
|
|
925
948
|
)
|
|
926
949
|
|
|
@@ -942,6 +965,54 @@ export function createServer() {
|
|
|
942
965
|
}
|
|
943
966
|
)
|
|
944
967
|
|
|
968
|
+
// Portal del cliente (ORQ-332, 17 oct 2026): UN link por proyecto, sin cuenta,
|
|
969
|
+
// que junta avance, aprobaciones de Contenido, docs públicos y novedades. Ver
|
|
970
|
+
// orquestra-infra/functions/api/lib/clientPortal.mjs. Ruta singleton sin id.
|
|
971
|
+
server.registerTool(
|
|
972
|
+
'get_client_portal_link',
|
|
973
|
+
{
|
|
974
|
+
description: 'Devuelve el link del portal del cliente del proyecto (una página pública, sin cuenta, para el cliente de un freelancer: avance, piezas de Contenido en revisión para aprobar o pedir cambios, lo ya aprobado, docs con visibilidad pública y novedades). `link: null` si no hay uno activo. El link es una credencial: compártelo solo con el cliente del proyecto.',
|
|
975
|
+
inputSchema: { projectId: z.string().optional().describe(PROJECT_ID_DESC) },
|
|
976
|
+
},
|
|
977
|
+
async ({ projectId }) => {
|
|
978
|
+
try {
|
|
979
|
+
return textResult(await apiGet(config, '/v1/client-portal', { projectId: resolveProjectId(projectId) }))
|
|
980
|
+
} catch (err) {
|
|
981
|
+
return errorResult(err)
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
server.registerTool(
|
|
987
|
+
'create_client_portal_link',
|
|
988
|
+
{
|
|
989
|
+
description: 'Genera el link del portal del cliente. Si ya había uno, lo REEMPLAZA: el anterior deja de funcionar para quien lo tenga -- confirma con el usuario antes de regenerar uno existente (revisa get_client_portal_link). Qué ve el cliente: avance, Contenido en estatus "review" (puede aprobar o pedir cambios), últimas 10 aprobadas, docs con visibility "public" y novedades del changelog. Nunca tareas, miembros ni notas.',
|
|
990
|
+
inputSchema: { projectId: z.string().optional().describe(PROJECT_ID_DESC) },
|
|
991
|
+
},
|
|
992
|
+
async ({ projectId }) => {
|
|
993
|
+
try {
|
|
994
|
+
return textResult(await apiPost(config, '/v1/client-portal', { projectId: resolveProjectId(projectId) }))
|
|
995
|
+
} catch (err) {
|
|
996
|
+
return errorResult(err)
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
server.registerTool(
|
|
1002
|
+
'revoke_client_portal_link',
|
|
1003
|
+
{
|
|
1004
|
+
description: 'Revoca el link del portal del cliente: el cliente deja de poder abrirlo (por ejemplo, al terminar el proyecto o si el link se filtró). No borra nada del proyecto. Para dar acceso de nuevo, create_client_portal_link genera uno distinto.',
|
|
1005
|
+
inputSchema: { projectId: z.string().optional().describe(PROJECT_ID_DESC) },
|
|
1006
|
+
},
|
|
1007
|
+
async ({ projectId }) => {
|
|
1008
|
+
try {
|
|
1009
|
+
return textResult(await apiDeletePath(config, '/v1/client-portal', { projectId: resolveProjectId(projectId) }))
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
return errorResult(err)
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
)
|
|
1015
|
+
|
|
945
1016
|
// Fusionar tareas (20 ago 2026) -- registradas directo con
|
|
946
1017
|
// server.registerTool (no registerCreate/registerUpdate) porque son
|
|
947
1018
|
// POST a `${path}/${id}/merge|unmerge`, no un CRUD estándar. Fusión
|
|
@@ -1809,6 +1880,7 @@ export function createServer() {
|
|
|
1809
1880
|
'/v1/ideas',
|
|
1810
1881
|
'Crea una idea en un proyecto de Orquestra.',
|
|
1811
1882
|
{
|
|
1883
|
+
relatedTo: RELATED_TO,
|
|
1812
1884
|
title: z.string().describe('Título de la idea'),
|
|
1813
1885
|
description: z.string().optional(),
|
|
1814
1886
|
priority: z.enum(TASK_PRIORITIES).optional().describe('Default: medium -- se puede derivar de impact+effort si los mandas (alto+bajo→high, alto+alto o bajo+bajo→medium, bajo+alto→low).'),
|
|
@@ -2007,6 +2079,7 @@ export function createServer() {
|
|
|
2007
2079
|
'/v1/changelog',
|
|
2008
2080
|
'Crea una entrada de changelog en un proyecto de Orquestra (estilo Keep a Changelog).',
|
|
2009
2081
|
{
|
|
2082
|
+
relatedTo: RELATED_TO,
|
|
2010
2083
|
section: z.enum(CHANGELOG_SECTIONS).describe('Sección del changelog'),
|
|
2011
2084
|
text: z.string().describe('Descripción de la entrada'),
|
|
2012
2085
|
version: z.string().optional(),
|
|
@@ -2057,6 +2130,7 @@ export function createServer() {
|
|
|
2057
2130
|
'/v1/notes',
|
|
2058
2131
|
'Crea una nota en un proyecto de Orquestra. En el texto, [[Título]] o [[Título|texto]] enlaza otra nota, doc, tarea o idea del proyecto (o [[ORQ-123]] por folio) -- úsalo para conectar el conocimiento en vez de pegar ids.',
|
|
2059
2132
|
{
|
|
2133
|
+
relatedTo: RELATED_TO,
|
|
2060
2134
|
title: z.string().describe('Título de la nota'),
|
|
2061
2135
|
body: z.string().optional(),
|
|
2062
2136
|
pinned: z.boolean().optional(),
|
|
@@ -2132,6 +2206,7 @@ export function createServer() {
|
|
|
2132
2206
|
'/v1/docs',
|
|
2133
2207
|
'Crea un documento en un proyecto de Orquestra. En el texto, [[Título]] o [[Título|texto]] enlaza otra nota, doc, tarea o idea del proyecto (o [[ORQ-123]] por folio) -- úsalo para conectar el conocimiento en vez de pegar ids.',
|
|
2134
2208
|
{
|
|
2209
|
+
relatedTo: RELATED_TO,
|
|
2135
2210
|
type: z.enum(DOC_TYPES).optional().describe('Tipo de documento -- obligatorio salvo que mandes templateId (la plantilla trae su tipo)'),
|
|
2136
2211
|
title: z.string().describe('Título del documento'),
|
|
2137
2212
|
content: z.string().optional(),
|
|
@@ -2252,6 +2327,7 @@ export function createServer() {
|
|
|
2252
2327
|
}
|
|
2253
2328
|
|
|
2254
2329
|
registerCreate('create_service', '/v1/services', 'Crea un servicio compartido de infraestructura (backend, base de datos, auth, etc.) en un proyecto. Además de status/notes, acepta los campos específicos de cada tipo (ej. apiUrl/region para backend, engine/provider para database) -- manda solo los que apliquen a `type`.', {
|
|
2330
|
+
relatedTo: RELATED_TO,
|
|
2255
2331
|
type: z.enum(SERVICE_TYPES).describe('Tipo de servicio'),
|
|
2256
2332
|
name: z.string().describe('Nombre del servicio'),
|
|
2257
2333
|
status: z.enum(INFRA_STATUSES).optional().describe('Default: operational'),
|
|
@@ -2317,6 +2393,7 @@ export function createServer() {
|
|
|
2317
2393
|
})
|
|
2318
2394
|
|
|
2319
2395
|
registerCreate('create_distribution', '/v1/distributions', 'Crea una distribución/plataforma (iOS, Android, Web, etc.) en un proyecto.', {
|
|
2396
|
+
relatedTo: RELATED_TO,
|
|
2320
2397
|
type: z.enum(PLATFORM_TYPES).describe('Tipo de plataforma'),
|
|
2321
2398
|
label: z.string().describe('Nombre a mostrar por default'),
|
|
2322
2399
|
name: z.string().optional().describe('Nombre editable -- si no se manda, la UI usa label'),
|
|
@@ -2454,6 +2531,7 @@ export function createServer() {
|
|
|
2454
2531
|
{}, (args, projectId) => ({ projectId }))
|
|
2455
2532
|
|
|
2456
2533
|
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).', {
|
|
2534
|
+
relatedTo: RELATED_TO,
|
|
2457
2535
|
title: z.string(),
|
|
2458
2536
|
description: z.string().optional(),
|
|
2459
2537
|
probability: z.enum(RISK_LEVELS).optional().describe('Default: medium'),
|
|
@@ -2480,6 +2558,7 @@ export function createServer() {
|
|
|
2480
2558
|
{}, (args, projectId) => ({ projectId }))
|
|
2481
2559
|
|
|
2482
2560
|
registerCreate('create_content', '/v1/content', 'Crea una pieza de contenido. Dos formas: (1) channelId suelto -- 1 canal, shape simple, seguí usándolo si solo publicás en un lado. (2) channels[] -- una pieza multi-canal con una variante por canal (cada una con su propio caption/status/fecha/desempeño), reemplaza tener que llamar esto varias veces para la "misma" publicación en distintos canales.', {
|
|
2561
|
+
relatedTo: RELATED_TO,
|
|
2483
2562
|
channelId: z.string().optional().describe('Canal donde se publica -- ver list_channels. Usalo para una pieza de 1 solo canal; si vas a publicar en varios, mandá channels[] en vez de esto.'),
|
|
2484
2563
|
channels: z.array(CONTENT_VARIANT_SCHEMA).optional().describe('Pieza multi-canal -- una entrada por canal. No mandes channelId si usás esto.'),
|
|
2485
2564
|
title: z.string().describe('Referencia interna, no es lo que se publica -- compartida entre todos los canales si es multi-canal'),
|
|
@@ -2557,7 +2636,7 @@ export function createServer() {
|
|
|
2557
2636
|
registerRead('list_agents', '/v1/agents',
|
|
2558
2637
|
'Lista los agentes (kind+máquina+stack+ruta local) configurados en un proyecto. Úsala antes de create_agent o de create_agent_run con stack, para saber si ya existe uno.',
|
|
2559
2638
|
{ kind: z.enum(AGENT_KINDS).optional().describe('Filtra por kind: "qa" (lint/test/build/E2E), "security" (npm audit + gitleaks), "performance" (build size + Lighthouse) o "screenshots" (captura de pantallas para el checklist de publicación, solo ios/android)') },
|
|
2560
|
-
(args, projectId) => ({ projectId }))
|
|
2639
|
+
(args, projectId) => ({ projectId, ...(args.kind ? { kind: args.kind } : {}) }))
|
|
2561
2640
|
|
|
2562
2641
|
registerCreate('create_agent', '/v1/agents', 'Crea un agente: asocia un kind ("qa" | "security" | "performance" | "screenshots") a una máquina (runner, ver Workspace → Máquinas), un stack de este proyecto y una ruta de checkout en ESA máquina. kind="performance" además necesita perfUrl.', {
|
|
2563
2642
|
name: z.string().describe('Nombre a mostrar, ej. "Agente web"'),
|