@neonexai/beebole-mcp 1.0.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 +157 -0
- package/dist/client.js +231 -0
- package/dist/index.js +80 -0
- package/dist/smoke.js +138 -0
- package/dist/tools.js +578 -0
- package/package.json +42 -0
- package/schema.json +1 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools del MCP de Beebole sobre la API GraphQL nueva (app.beebole.com/graphql).
|
|
3
|
+
*
|
|
4
|
+
* Arquitectura HÍBRIDA (la API tiene 87 queries + 745 mutations → inviable 1:1):
|
|
5
|
+
* A) ~24 tools CURADAS para el flujo real de un estudio (Cota Zero): identidad,
|
|
6
|
+
* proyectos/tareas/personas, fichaje de horas (alta/edición/borrado),
|
|
7
|
+
* timesheets (enviar/aprobar/rechazar), tags, ausencias e informes.
|
|
8
|
+
* B) 3 tools GENÉRICAS para cobertura del 100%:
|
|
9
|
+
* - beebole_search_schema descubre cualquier operación por palabra clave
|
|
10
|
+
* - beebole_describe_operation da su firma completa (args + inputs + retorno)
|
|
11
|
+
* - beebole_graphql ejecuta cualquier query/mutation cruda
|
|
12
|
+
*
|
|
13
|
+
* Con A+B el agente resuelve el 80% con tools claras y el 20% restante (las ~800
|
|
14
|
+
* operaciones de administración/config) vía descubrir → describir → ejecutar.
|
|
15
|
+
*
|
|
16
|
+
* Factoría: buildServer(apiKey) → McpServer ligado a un cliente (un token). En
|
|
17
|
+
* stdio el token viene del entorno; en HTTP, de la cabecera por petición.
|
|
18
|
+
*/
|
|
19
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { BeeboleClient, BeeboleError, selection, searchOps, describeOp, gqlArgs } from './client.js';
|
|
22
|
+
const VERSION = '1.0.0';
|
|
23
|
+
// Selecciones reutilizables (compactas para listas, ricas para "get").
|
|
24
|
+
const SEL_PROJECT = () => selection('BeeboleProject', 0);
|
|
25
|
+
const SEL_TASK = () => selection('BeeboleTask', 0);
|
|
26
|
+
const SEL_PERSON = () => selection('BeebolePerson', 0);
|
|
27
|
+
const SEL_TR = () => selection('BeeboleTimeRecord', 1);
|
|
28
|
+
const SEL_TAG = () => selection('BeeboleTag', 0);
|
|
29
|
+
const SEL_ABS = () => selection('BeeboleAbsenceType', 0);
|
|
30
|
+
const SEL_REPORT = () => selection('BeeboleReport', 0);
|
|
31
|
+
const SEL_EVENT = () => selection('BeeboleApprovalEvent', 0);
|
|
32
|
+
function ok(data) {
|
|
33
|
+
const json = JSON.stringify(data, null, 2);
|
|
34
|
+
const text = json.length > 100_000 ? json.slice(0, 100_000) + '\n…(truncado)…' : json;
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: 'text', text }],
|
|
37
|
+
structuredContent: data && typeof data === 'object' ? data : { value: data },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function fail(err) {
|
|
41
|
+
const msg = err instanceof BeeboleError ? err.message : `Error inesperado: ${err.message}`;
|
|
42
|
+
return { content: [{ type: 'text', text: `❌ ${msg}` }], isError: true };
|
|
43
|
+
}
|
|
44
|
+
/** Envuelve un handler async con try/catch → ToolResult de error accionable. */
|
|
45
|
+
function guard(fn) {
|
|
46
|
+
return async (args) => {
|
|
47
|
+
try {
|
|
48
|
+
return await fn(args);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return fail(err);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const TS = z.number().int().describe('Unix timestamp en MILISEGUNDOS (epoch ms, > 1e10). Ej: 1750000000000.');
|
|
56
|
+
const ID = z.string().describe('BeeboleId (ObjectId, 24 hex).');
|
|
57
|
+
export function buildServer(apiKey) {
|
|
58
|
+
const beebole = new BeeboleClient(apiKey);
|
|
59
|
+
const server = new McpServer({ name: 'beebole-mcp', version: VERSION });
|
|
60
|
+
const RO = { readOnlyHint: true, openWorldHint: true };
|
|
61
|
+
const WR = { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
|
|
62
|
+
const DESTR = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
|
|
63
|
+
// ── A) Identidad / contexto ────────────────────────────────────────────────
|
|
64
|
+
server.registerTool('beebole_whoami', {
|
|
65
|
+
title: 'Quién soy + organización',
|
|
66
|
+
description: 'Devuelve la persona autenticada por la API key (id, nombre, email, rol) y su organización. Empieza por aquí para conocer tu propio personId y el contexto de la cuenta.',
|
|
67
|
+
inputSchema: {},
|
|
68
|
+
annotations: RO,
|
|
69
|
+
}, guard(async () => {
|
|
70
|
+
const q = `query{ currentPerson{ ${selection('BeebolePerson', 1)} } currentOrganisation{ ${selection('BeeboleOrganisation', 0)} } }`;
|
|
71
|
+
return ok(await beebole.graphql(q));
|
|
72
|
+
}));
|
|
73
|
+
// ── A) Lecturas: proyectos / tareas / personas ─────────────────────────────
|
|
74
|
+
server.registerTool('beebole_list_projects', {
|
|
75
|
+
title: 'Listar proyectos',
|
|
76
|
+
description: 'Lista proyectos con filtros opcionales. Sin filtros devuelve los proyectos de nivel raíz activos.',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
archived: z.boolean().optional().describe('Incluir/solo archivados.'),
|
|
79
|
+
name: z.string().optional().describe('Filtra por nombre (substring).'),
|
|
80
|
+
categoryId: ID.optional(),
|
|
81
|
+
tagIds: z.array(z.string()).optional().describe('Solo proyectos con estos tags.'),
|
|
82
|
+
assignedPersonId: ID.optional(),
|
|
83
|
+
managedById: ID.optional(),
|
|
84
|
+
},
|
|
85
|
+
annotations: RO,
|
|
86
|
+
}, guard(async (a) => {
|
|
87
|
+
const filter = {};
|
|
88
|
+
if (a.name !== undefined)
|
|
89
|
+
filter.name = a.name;
|
|
90
|
+
if (a.tagIds !== undefined)
|
|
91
|
+
filter.tagIds = a.tagIds;
|
|
92
|
+
if (a.assignedPersonId !== undefined)
|
|
93
|
+
filter.assignedPersonId = a.assignedPersonId;
|
|
94
|
+
if (a.managedById !== undefined)
|
|
95
|
+
filter.managedById = a.managedById;
|
|
96
|
+
const { decls, args, variables } = gqlArgs([
|
|
97
|
+
{ name: 'filter', type: '[BeeboleProjectFilter]', value: Object.keys(filter).length ? [filter] : undefined },
|
|
98
|
+
{ name: 'archived', type: 'Boolean', value: a.archived },
|
|
99
|
+
{ name: 'categoryId', type: 'BeeboleId', value: a.categoryId },
|
|
100
|
+
]);
|
|
101
|
+
const q = `query${decls}{ getProjects${args}{ ${SEL_PROJECT()} } }`;
|
|
102
|
+
const d = await beebole.graphql(q, variables);
|
|
103
|
+
return ok(d.getProjects);
|
|
104
|
+
}));
|
|
105
|
+
server.registerTool('beebole_get_project', {
|
|
106
|
+
title: 'Detalle de un proyecto',
|
|
107
|
+
description: 'Devuelve un proyecto por id con detalle (categoría, padre, managers, budgets).',
|
|
108
|
+
inputSchema: { id: ID },
|
|
109
|
+
annotations: RO,
|
|
110
|
+
}, guard(async (a) => {
|
|
111
|
+
const q = `query($id:BeeboleId!){ getProject(id:$id){ ${selection('BeeboleProject', 1)} } }`;
|
|
112
|
+
return ok((await beebole.graphql(q, { id: a.id })).getProject);
|
|
113
|
+
}));
|
|
114
|
+
server.registerTool('beebole_list_tasks', {
|
|
115
|
+
title: 'Listar tareas',
|
|
116
|
+
description: 'Lista tareas con filtros (proyecto, estado, responsable, persona asignada, categoría, rango).',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
projectId: ID.optional(),
|
|
119
|
+
statusId: ID.optional(),
|
|
120
|
+
ownerId: ID.optional(),
|
|
121
|
+
assignedPersonId: ID.optional(),
|
|
122
|
+
name: z.string().optional(),
|
|
123
|
+
archived: z.boolean().optional(),
|
|
124
|
+
categoryId: ID.optional(),
|
|
125
|
+
startTime: TS.optional(),
|
|
126
|
+
endTime: TS.optional(),
|
|
127
|
+
},
|
|
128
|
+
annotations: RO,
|
|
129
|
+
}, guard(async (a) => {
|
|
130
|
+
const filter = {};
|
|
131
|
+
for (const k of ['projectId', 'statusId', 'ownerId', 'assignedPersonId', 'name'])
|
|
132
|
+
if (a[k] !== undefined)
|
|
133
|
+
filter[k] = a[k];
|
|
134
|
+
const { decls, args, variables } = gqlArgs([
|
|
135
|
+
{ name: 'filter', type: '[BeeboleTaskFilter]', value: Object.keys(filter).length ? [filter] : undefined },
|
|
136
|
+
{ name: 'archived', type: 'Boolean', value: a.archived },
|
|
137
|
+
{ name: 'categoryId', type: 'BeeboleId', value: a.categoryId },
|
|
138
|
+
{ name: 'startTime', type: 'BeeboleTimestamp', value: a.startTime },
|
|
139
|
+
{ name: 'endTime', type: 'BeeboleTimestamp', value: a.endTime },
|
|
140
|
+
]);
|
|
141
|
+
const q = `query${decls}{ getTasks${args}{ ${SEL_TASK()} } }`;
|
|
142
|
+
return ok((await beebole.graphql(q, variables)).getTasks);
|
|
143
|
+
}));
|
|
144
|
+
server.registerTool('beebole_get_task', {
|
|
145
|
+
title: 'Detalle de una tarea',
|
|
146
|
+
description: 'Devuelve una tarea por id con detalle.',
|
|
147
|
+
inputSchema: { id: ID },
|
|
148
|
+
annotations: RO,
|
|
149
|
+
}, guard(async (a) => {
|
|
150
|
+
const q = `query($id:BeeboleId!){ getTask(id:$id){ ${selection('BeeboleTask', 1)} } }`;
|
|
151
|
+
return ok((await beebole.graphql(q, { id: a.id })).getTask);
|
|
152
|
+
}));
|
|
153
|
+
server.registerTool('beebole_list_persons', {
|
|
154
|
+
title: 'Listar personas',
|
|
155
|
+
description: 'Lista las personas (usuarios) de la organización, con filtros opcionales.',
|
|
156
|
+
inputSchema: {
|
|
157
|
+
archived: z.boolean().optional(),
|
|
158
|
+
roleId: ID.optional(),
|
|
159
|
+
name: z.string().optional(),
|
|
160
|
+
tagIds: z.array(z.string()).optional(),
|
|
161
|
+
},
|
|
162
|
+
annotations: RO,
|
|
163
|
+
}, guard(async (a) => {
|
|
164
|
+
const filter = {};
|
|
165
|
+
if (a.roleId !== undefined)
|
|
166
|
+
filter.roleId = a.roleId;
|
|
167
|
+
if (a.name !== undefined)
|
|
168
|
+
filter.name = a.name;
|
|
169
|
+
if (a.tagIds !== undefined)
|
|
170
|
+
filter.tagIds = a.tagIds;
|
|
171
|
+
const { decls, args, variables } = gqlArgs([
|
|
172
|
+
{ name: 'filter', type: '[BeebolePersonFilter]', value: Object.keys(filter).length ? [filter] : undefined },
|
|
173
|
+
{ name: 'archived', type: 'Boolean', value: a.archived },
|
|
174
|
+
]);
|
|
175
|
+
const q = `query${decls}{ getPersons${args}{ ${SEL_PERSON()} } }`;
|
|
176
|
+
return ok((await beebole.graphql(q, variables)).getPersons);
|
|
177
|
+
}));
|
|
178
|
+
server.registerTool('beebole_get_person', {
|
|
179
|
+
title: 'Detalle de una persona',
|
|
180
|
+
description: 'Devuelve una persona por id con detalle (rol, organización, ajustes).',
|
|
181
|
+
inputSchema: { id: ID },
|
|
182
|
+
annotations: RO,
|
|
183
|
+
}, guard(async (a) => {
|
|
184
|
+
const q = `query($id:BeeboleId!){ getPerson(id:$id){ ${selection('BeebolePerson', 1)} } }`;
|
|
185
|
+
return ok((await beebole.graphql(q, { id: a.id })).getPerson);
|
|
186
|
+
}));
|
|
187
|
+
// ── A) Time records (fichaje de horas — el núcleo) ─────────────────────────
|
|
188
|
+
const trFilterSchema = {
|
|
189
|
+
startTime: TS.optional().describe('Inicio del rango (epoch ms).'),
|
|
190
|
+
endTime: TS.optional().describe('Fin del rango (epoch ms).'),
|
|
191
|
+
personId: ID.optional(),
|
|
192
|
+
personIds: z.array(z.string()).optional(),
|
|
193
|
+
projectIds: z.array(z.string()).optional(),
|
|
194
|
+
taskIds: z.array(z.string()).optional(),
|
|
195
|
+
status: z.enum(['d', 's', 'a', 'r']).optional().describe('d=draft, s=submitted, a=approved, r=rejected.'),
|
|
196
|
+
onlyTime: z.boolean().optional().describe('Solo imputaciones de tiempo (no ausencias).'),
|
|
197
|
+
onlyAbsence: z.boolean().optional().describe('Solo ausencias.'),
|
|
198
|
+
wfh: z.boolean().optional().describe('Solo teletrabajo.'),
|
|
199
|
+
};
|
|
200
|
+
function trArgs(a) {
|
|
201
|
+
const filter = {};
|
|
202
|
+
for (const k of ['personId', 'personIds', 'projectIds', 'taskIds', 'status'])
|
|
203
|
+
if (a[k] !== undefined)
|
|
204
|
+
filter[k] = a[k];
|
|
205
|
+
return gqlArgs([
|
|
206
|
+
{ name: 'startTime', type: 'BeeboleTimestamp', value: a.startTime },
|
|
207
|
+
{ name: 'endTime', type: 'BeeboleTimestamp', value: a.endTime },
|
|
208
|
+
{ name: 'time', type: 'Boolean', value: a.onlyTime },
|
|
209
|
+
{ name: 'absence', type: 'Boolean', value: a.onlyAbsence },
|
|
210
|
+
{ name: 'wfh', type: 'Boolean', value: a.wfh },
|
|
211
|
+
{ name: 'filter', type: '[BeeboleTimeRecordFilter]', value: Object.keys(filter).length ? [filter] : undefined },
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
server.registerTool('beebole_list_time_records', {
|
|
215
|
+
title: 'Listar imputaciones de horas',
|
|
216
|
+
description: 'Lista los time records (horas/ausencias fichadas) en un rango con filtros por persona, proyecto, tarea o estado. Pasa startTime/endTime (epoch ms) para acotar.',
|
|
217
|
+
inputSchema: trFilterSchema,
|
|
218
|
+
annotations: RO,
|
|
219
|
+
}, guard(async (a) => {
|
|
220
|
+
const { decls, args, variables } = trArgs(a);
|
|
221
|
+
const q = `query${decls}{ getTimeRecords${args}{ ${SEL_TR()} } }`;
|
|
222
|
+
return ok((await beebole.graphql(q, variables)).getTimeRecords);
|
|
223
|
+
}));
|
|
224
|
+
server.registerTool('beebole_count_time_records', {
|
|
225
|
+
title: 'Contar imputaciones',
|
|
226
|
+
description: 'Cuenta los time records que casan el filtro (útil antes de listar rangos grandes).',
|
|
227
|
+
inputSchema: trFilterSchema,
|
|
228
|
+
annotations: RO,
|
|
229
|
+
}, guard(async (a) => {
|
|
230
|
+
const { decls, args, variables } = trArgs(a);
|
|
231
|
+
const q = `query${decls}{ countTimeRecords${args} }`;
|
|
232
|
+
return ok(await beebole.graphql(q, variables));
|
|
233
|
+
}));
|
|
234
|
+
server.registerTool('beebole_add_time_record', {
|
|
235
|
+
title: 'Fichar horas (crear imputación)',
|
|
236
|
+
description: 'Crea una imputación de horas para una persona. duration en MINUTOS. startTime en epoch ms. Indica taskId (y opcionalmente projectIds) para tiempo, o absenceId para una ausencia. comment/nonBillable/wfh se aplican tras crear.',
|
|
237
|
+
inputSchema: {
|
|
238
|
+
personId: ID,
|
|
239
|
+
startTime: TS.describe('Inicio (epoch ms). Para fichaje por día, usa el inicio del día.'),
|
|
240
|
+
durationMinutes: z.number().int().positive().describe('Duración en minutos (ej. 90 = 1h30).'),
|
|
241
|
+
endTime: TS.optional(),
|
|
242
|
+
taskId: ID.optional(),
|
|
243
|
+
projectIds: z.array(z.string()).optional(),
|
|
244
|
+
absenceId: ID.optional().describe('Para fichar una ausencia en lugar de tiempo.'),
|
|
245
|
+
comment: z.string().optional(),
|
|
246
|
+
nonBillable: z.boolean().optional(),
|
|
247
|
+
wfh: z.boolean().optional(),
|
|
248
|
+
},
|
|
249
|
+
annotations: WR,
|
|
250
|
+
}, guard(async (a) => {
|
|
251
|
+
const { decls, args, variables } = gqlArgs([
|
|
252
|
+
{ name: 'startTime', type: 'BeeboleTimestamp!', value: a.startTime },
|
|
253
|
+
{ name: 'endTime', type: 'BeeboleTimestamp', value: a.endTime },
|
|
254
|
+
{ name: 'duration', type: 'Int!', value: a.durationMinutes },
|
|
255
|
+
{ name: 'personId', type: 'BeeboleId!', value: a.personId },
|
|
256
|
+
{ name: 'taskId', type: 'BeeboleId', value: a.taskId },
|
|
257
|
+
{ name: 'absenceId', type: 'BeeboleId', value: a.absenceId },
|
|
258
|
+
{ name: 'projectIds', type: '[BeeboleId]', value: a.projectIds },
|
|
259
|
+
]);
|
|
260
|
+
const q = `mutation${decls}{ addTimeRecord${args}{ id } }`;
|
|
261
|
+
const created = (await beebole.graphql(q, variables)).addTimeRecord;
|
|
262
|
+
const id = created.id;
|
|
263
|
+
// Campos no soportados por addTimeRecord → se aplican con edits puntuales.
|
|
264
|
+
const edits = await applyTimeRecordEdits(beebole, id, {
|
|
265
|
+
comment: a.comment,
|
|
266
|
+
nonBillable: a.nonBillable,
|
|
267
|
+
wfh: a.wfh,
|
|
268
|
+
});
|
|
269
|
+
if (edits)
|
|
270
|
+
return ok(edits);
|
|
271
|
+
const got = `query($id:BeeboleId!){ getTimeRecord(id:$id){ ${SEL_TR()} } }`;
|
|
272
|
+
return ok((await beebole.graphql(got, { id })).getTimeRecord);
|
|
273
|
+
}));
|
|
274
|
+
server.registerTool('beebole_edit_time_record', {
|
|
275
|
+
title: 'Editar una imputación',
|
|
276
|
+
description: 'Modifica campos de un time record existente. Pasa solo los que quieras cambiar: comment, durationMinutes, startTime, endTime, taskId, projectIds, nonBillable, wfh.',
|
|
277
|
+
inputSchema: {
|
|
278
|
+
id: ID,
|
|
279
|
+
comment: z.string().optional(),
|
|
280
|
+
durationMinutes: z.number().int().positive().optional(),
|
|
281
|
+
startTime: TS.optional(),
|
|
282
|
+
endTime: TS.optional(),
|
|
283
|
+
taskId: ID.optional(),
|
|
284
|
+
projectIds: z.array(z.string()).optional(),
|
|
285
|
+
nonBillable: z.boolean().optional(),
|
|
286
|
+
wfh: z.boolean().optional(),
|
|
287
|
+
},
|
|
288
|
+
annotations: WR,
|
|
289
|
+
}, guard(async (a) => {
|
|
290
|
+
const result = await applyTimeRecordEdits(beebole, a.id, a);
|
|
291
|
+
if (!result)
|
|
292
|
+
throw new BeeboleError('No indicaste ningún campo que cambiar.');
|
|
293
|
+
return ok(result);
|
|
294
|
+
}));
|
|
295
|
+
server.registerTool('beebole_delete_time_records', {
|
|
296
|
+
title: 'Borrar imputaciones',
|
|
297
|
+
description: 'Elimina uno o varios time records por id. Acción destructiva e irreversible.',
|
|
298
|
+
inputSchema: { ids: z.array(z.string()).min(1) },
|
|
299
|
+
annotations: DESTR,
|
|
300
|
+
}, guard(async (a) => {
|
|
301
|
+
const q = `mutation($ids:[BeeboleId!]!){ deleteTimeRecords(ids:$ids){ id } }`;
|
|
302
|
+
return ok(await beebole.graphql(q, { ids: a.ids }));
|
|
303
|
+
}));
|
|
304
|
+
server.registerTool('beebole_clone_time_records', {
|
|
305
|
+
title: 'Clonar imputaciones a otro periodo',
|
|
306
|
+
description: 'Copia los time records de una persona de un periodo origen a uno destino (epoch ms).',
|
|
307
|
+
inputSchema: {
|
|
308
|
+
personId: ID,
|
|
309
|
+
sourceStartTime: TS,
|
|
310
|
+
sourceEndTime: TS,
|
|
311
|
+
targetStartTime: TS,
|
|
312
|
+
targetEndTime: TS,
|
|
313
|
+
replaceExisting: z.boolean().optional(),
|
|
314
|
+
},
|
|
315
|
+
annotations: WR,
|
|
316
|
+
}, guard(async (a) => {
|
|
317
|
+
const q = `mutation($personId:BeeboleId!,$ss:BeeboleTimestamp!,$se:BeeboleTimestamp!,$ts:BeeboleTimestamp!,$te:BeeboleTimestamp!,$r:Boolean){ cloneTimeRecords(personId:$personId,sourceStartTime:$ss,sourceEndTime:$se,targetStartTime:$ts,targetEndTime:$te,replaceExisting:$r){ id } }`;
|
|
318
|
+
return ok(await beebole.graphql(q, {
|
|
319
|
+
personId: a.personId,
|
|
320
|
+
ss: a.sourceStartTime,
|
|
321
|
+
se: a.sourceEndTime,
|
|
322
|
+
ts: a.targetStartTime,
|
|
323
|
+
te: a.targetEndTime,
|
|
324
|
+
r: a.replaceExisting,
|
|
325
|
+
}));
|
|
326
|
+
}));
|
|
327
|
+
// ── A) Timesheets (workflow de aprobación) ─────────────────────────────────
|
|
328
|
+
server.registerTool('beebole_submit_timesheet', {
|
|
329
|
+
title: 'Enviar timesheet a aprobación',
|
|
330
|
+
description: 'Envía el timesheet de una persona para un periodo (epoch ms) al flujo de aprobación.',
|
|
331
|
+
inputSchema: { personId: ID, startTime: TS, endTime: TS },
|
|
332
|
+
annotations: WR,
|
|
333
|
+
}, guard(async (a) => {
|
|
334
|
+
const q = `mutation($personId:BeeboleId!,$s:BeeboleTimestamp!,$e:BeeboleTimestamp!){ submitTimesheet(personId:$personId,startTime:$s,endTime:$e){ ${SEL_EVENT()} } }`;
|
|
335
|
+
return ok(await beebole.graphql(q, { personId: a.personId, s: a.startTime, e: a.endTime }));
|
|
336
|
+
}));
|
|
337
|
+
server.registerTool('beebole_approve_timesheet', {
|
|
338
|
+
title: 'Aprobar timesheet',
|
|
339
|
+
description: 'Aprueba un evento de aprobación de timesheet por su id (lo da getPendingApprovals / submit).',
|
|
340
|
+
inputSchema: { id: ID.describe('id del approval event.') },
|
|
341
|
+
annotations: WR,
|
|
342
|
+
}, guard(async (a) => {
|
|
343
|
+
const q = `mutation($id:BeeboleId!){ approveTimesheet(id:$id){ ${SEL_EVENT()} } }`;
|
|
344
|
+
return ok(await beebole.graphql(q, { id: a.id }));
|
|
345
|
+
}));
|
|
346
|
+
server.registerTool('beebole_reject_timesheet', {
|
|
347
|
+
title: 'Rechazar timesheet',
|
|
348
|
+
description: 'Rechaza un timesheet en una etapa concreta con un comentario obligatorio.',
|
|
349
|
+
inputSchema: { id: ID, stage: z.number().int(), comment: z.string().min(1) },
|
|
350
|
+
annotations: DESTR,
|
|
351
|
+
}, guard(async (a) => {
|
|
352
|
+
const q = `mutation($id:BeeboleId!,$stage:Int!,$comment:String!){ rejectTimesheet(id:$id,stage:$stage,comment:$comment){ ${SEL_EVENT()} } }`;
|
|
353
|
+
return ok(await beebole.graphql(q, { id: a.id, stage: a.stage, comment: a.comment }));
|
|
354
|
+
}));
|
|
355
|
+
// ── A) Catálogos: tags, ausencias ──────────────────────────────────────────
|
|
356
|
+
server.registerTool('beebole_list_tags', {
|
|
357
|
+
title: 'Listar tags/grupos',
|
|
358
|
+
description: 'Lista los tags (grupos jerárquicos) usados para clasificar personas/proyectos/tareas.',
|
|
359
|
+
inputSchema: { archived: z.boolean().optional(), name: z.string().optional(), categoryId: ID.optional() },
|
|
360
|
+
annotations: RO,
|
|
361
|
+
}, guard(async (a) => {
|
|
362
|
+
const filter = {};
|
|
363
|
+
if (a.name !== undefined)
|
|
364
|
+
filter.name = a.name;
|
|
365
|
+
const { decls, args, variables } = gqlArgs([
|
|
366
|
+
{ name: 'filter', type: '[BeeboleTagFilter]', value: Object.keys(filter).length ? [filter] : undefined },
|
|
367
|
+
{ name: 'archived', type: 'Boolean', value: a.archived },
|
|
368
|
+
{ name: 'categoryId', type: 'BeeboleId', value: a.categoryId },
|
|
369
|
+
]);
|
|
370
|
+
const q = `query${decls}{ getTags${args}{ ${SEL_TAG()} } }`;
|
|
371
|
+
return ok((await beebole.graphql(q, variables)).getTags);
|
|
372
|
+
}));
|
|
373
|
+
server.registerTool('beebole_list_absence_types', {
|
|
374
|
+
title: 'Listar tipos de ausencia',
|
|
375
|
+
description: 'Lista los tipos de ausencia (vacaciones, baja, etc.) con su unidad (día/hora).',
|
|
376
|
+
inputSchema: { archived: z.boolean().optional() },
|
|
377
|
+
annotations: RO,
|
|
378
|
+
}, guard(async (a) => {
|
|
379
|
+
const { decls, args, variables } = gqlArgs([{ name: 'archived', type: 'Boolean', value: a.archived }]);
|
|
380
|
+
const q = `query${decls}{ getAbsenceTypes${args}{ ${SEL_ABS()} } }`;
|
|
381
|
+
return ok((await beebole.graphql(q, variables)).getAbsenceTypes);
|
|
382
|
+
}));
|
|
383
|
+
// ── A) Informes ────────────────────────────────────────────────────────────
|
|
384
|
+
server.registerTool('beebole_list_reports', {
|
|
385
|
+
title: 'Listar informes guardados',
|
|
386
|
+
description: 'Lista los informes definidos en la cuenta (para ejecutarlos con beebole_run_report).',
|
|
387
|
+
inputSchema: {},
|
|
388
|
+
annotations: RO,
|
|
389
|
+
}, guard(async () => {
|
|
390
|
+
const q = `query{ getReports{ ${SEL_REPORT()} } }`;
|
|
391
|
+
return ok((await beebole.graphql(q)).getReports);
|
|
392
|
+
}));
|
|
393
|
+
server.registerTool('beebole_run_report', {
|
|
394
|
+
title: 'Ejecutar un informe guardado',
|
|
395
|
+
description: 'Ejecuta un informe guardado por id y devuelve sus filas. Hace polling si el informe es asíncrono (campo pending). period/filters son opcionales para sobrescribir el rango.',
|
|
396
|
+
inputSchema: {
|
|
397
|
+
id: ID,
|
|
398
|
+
period: z
|
|
399
|
+
.object({
|
|
400
|
+
target: z.enum(['current', 'previous', 'next', 'yearToDay', 'last12Months', 'custom']),
|
|
401
|
+
period: z.enum(['day', 'week', 'biWeekly', 'semiMonth', 'month', 'quarter', 'year']).optional(),
|
|
402
|
+
start: TS.optional(),
|
|
403
|
+
end: TS.optional(),
|
|
404
|
+
})
|
|
405
|
+
.optional()
|
|
406
|
+
.describe('Periodo a aplicar; con target="custom" usa start/end (epoch ms).'),
|
|
407
|
+
filters: z.array(z.record(z.string(), z.unknown())).optional().describe('Array de BeeboleReportFilter.'),
|
|
408
|
+
},
|
|
409
|
+
annotations: RO,
|
|
410
|
+
}, guard(async (a) => {
|
|
411
|
+
const { decls, args, variables } = gqlArgs([
|
|
412
|
+
{ name: 'id', type: 'BeeboleId!', value: a.id },
|
|
413
|
+
{ name: 'period', type: 'BeeboleReportParamPeriodInput', value: a.period },
|
|
414
|
+
{ name: 'filters', type: '[BeeboleReportFilter]', value: a.filters },
|
|
415
|
+
]);
|
|
416
|
+
const runQ = `query${decls}{ x:runReport${args}{ ${selection('BeeboleReportResult', 1)} } }`;
|
|
417
|
+
let result = (await beebole.graphql(runQ, variables)).x;
|
|
418
|
+
for (let i = 0; result?.pending && i < 20; i++) {
|
|
419
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
420
|
+
const pq = `query($id:BeeboleId!){ getReportResult(id:$id){ ${selection('BeeboleReportResult', 1)} } }`;
|
|
421
|
+
result = (await beebole.graphql(pq, { id: result.id })).getReportResult;
|
|
422
|
+
}
|
|
423
|
+
return ok(result);
|
|
424
|
+
}));
|
|
425
|
+
server.registerTool('beebole_planned_vs_real', {
|
|
426
|
+
title: 'Informe planificado vs real',
|
|
427
|
+
description: 'Compara horas planificadas vs reales en un rango (epoch ms). Útil para seguimiento de presupuesto de proyecto. Filtra por proyecto/persona vía filters.',
|
|
428
|
+
inputSchema: {
|
|
429
|
+
startTime: TS,
|
|
430
|
+
endTime: TS,
|
|
431
|
+
filters: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
432
|
+
taskCategoryId: ID.optional(),
|
|
433
|
+
periodSplit: z.string().optional().describe('p.ej. "month", "week".'),
|
|
434
|
+
},
|
|
435
|
+
annotations: RO,
|
|
436
|
+
}, guard(async (a) => {
|
|
437
|
+
const { decls, args, variables } = gqlArgs([
|
|
438
|
+
{ name: 'startTime', type: 'BeeboleTimestamp!', value: a.startTime },
|
|
439
|
+
{ name: 'endTime', type: 'BeeboleTimestamp!', value: a.endTime },
|
|
440
|
+
{ name: 'filters', type: '[BeeboleReportFilter]', value: a.filters },
|
|
441
|
+
{ name: 'taskCategoryId', type: 'BeeboleId', value: a.taskCategoryId },
|
|
442
|
+
{ name: 'periodSplit', type: 'String', value: a.periodSplit },
|
|
443
|
+
]);
|
|
444
|
+
const q = `query${decls}{ getPlannedVsRealReport${args}{ ${selection('BeebolePlannedVsRealResult', 1)} } }`;
|
|
445
|
+
return ok(await beebole.graphql(q, variables));
|
|
446
|
+
}));
|
|
447
|
+
// ── A) Altas de catálogo ───────────────────────────────────────────────────
|
|
448
|
+
server.registerTool('beebole_add_project', {
|
|
449
|
+
title: 'Crear proyecto',
|
|
450
|
+
description: 'Crea un proyecto. color = índice de paleta 0-71.',
|
|
451
|
+
inputSchema: {
|
|
452
|
+
name: z.string().min(1).max(160),
|
|
453
|
+
categoryId: ID.optional(),
|
|
454
|
+
parentId: ID.optional(),
|
|
455
|
+
color: z.number().int().min(0).max(71).optional(),
|
|
456
|
+
},
|
|
457
|
+
annotations: WR,
|
|
458
|
+
}, guard(async (a) => {
|
|
459
|
+
const { decls, args, variables } = gqlArgs([
|
|
460
|
+
{ name: 'name', type: 'BeeboleName!', value: a.name },
|
|
461
|
+
{ name: 'categoryId', type: 'BeeboleId', value: a.categoryId },
|
|
462
|
+
{ name: 'parentId', type: 'BeeboleId', value: a.parentId },
|
|
463
|
+
{ name: 'color', type: 'BeeboleColor', value: a.color },
|
|
464
|
+
]);
|
|
465
|
+
const q = `mutation${decls}{ addProject${args}{ ${SEL_PROJECT()} } }`;
|
|
466
|
+
return ok(await beebole.graphql(q, variables));
|
|
467
|
+
}));
|
|
468
|
+
server.registerTool('beebole_add_task', {
|
|
469
|
+
title: 'Crear tarea',
|
|
470
|
+
description: 'Crea una tarea (opcionalmente bajo un proyecto/padre y con estado/categoría).',
|
|
471
|
+
inputSchema: {
|
|
472
|
+
name: z.string().min(1).max(160),
|
|
473
|
+
categoryId: ID.optional(),
|
|
474
|
+
parentId: ID.optional(),
|
|
475
|
+
statusId: ID.optional(),
|
|
476
|
+
color: z.number().int().min(0).max(71).optional(),
|
|
477
|
+
},
|
|
478
|
+
annotations: WR,
|
|
479
|
+
}, guard(async (a) => {
|
|
480
|
+
const { decls, args, variables } = gqlArgs([
|
|
481
|
+
{ name: 'name', type: 'BeeboleName!', value: a.name },
|
|
482
|
+
{ name: 'categoryId', type: 'BeeboleId', value: a.categoryId },
|
|
483
|
+
{ name: 'parentId', type: 'BeeboleId', value: a.parentId },
|
|
484
|
+
{ name: 'statusId', type: 'BeeboleId', value: a.statusId },
|
|
485
|
+
{ name: 'color', type: 'BeeboleColor', value: a.color },
|
|
486
|
+
]);
|
|
487
|
+
const q = `mutation${decls}{ addTask${args}{ ${SEL_TASK()} } }`;
|
|
488
|
+
return ok(await beebole.graphql(q, variables));
|
|
489
|
+
}));
|
|
490
|
+
server.registerTool('beebole_add_person', {
|
|
491
|
+
title: 'Crear persona',
|
|
492
|
+
description: 'Da de alta una persona (usuario). roleId es obligatorio (ver roles de la organización).',
|
|
493
|
+
inputSchema: {
|
|
494
|
+
name: z.string().min(1).max(160),
|
|
495
|
+
email: z.string().email(),
|
|
496
|
+
roleId: ID,
|
|
497
|
+
color: z.number().int().min(0).max(71).optional(),
|
|
498
|
+
},
|
|
499
|
+
annotations: WR,
|
|
500
|
+
}, guard(async (a) => {
|
|
501
|
+
const { decls, args, variables } = gqlArgs([
|
|
502
|
+
{ name: 'name', type: 'BeeboleName!', value: a.name },
|
|
503
|
+
{ name: 'email', type: 'BeeboleEmail!', value: a.email },
|
|
504
|
+
{ name: 'roleId', type: 'BeeboleId!', value: a.roleId },
|
|
505
|
+
{ name: 'color', type: 'BeeboleColor', value: a.color },
|
|
506
|
+
]);
|
|
507
|
+
const q = `mutation${decls}{ addPerson${args}{ ${SEL_PERSON()} } }`;
|
|
508
|
+
return ok(await beebole.graphql(q, variables));
|
|
509
|
+
}));
|
|
510
|
+
// ── B) Genéricas: cobertura del 100% de la API ─────────────────────────────
|
|
511
|
+
server.registerTool('beebole_search_schema', {
|
|
512
|
+
title: 'Buscar operaciones en la API',
|
|
513
|
+
description: 'Descubre cualquiera de las 87 queries + 745 mutations de Beebole por palabra clave (nombre o descripción). Úsalo cuando no haya una tool curada para lo que necesitas; luego beebole_describe_operation y beebole_graphql.',
|
|
514
|
+
inputSchema: {
|
|
515
|
+
keyword: z.string().min(2).describe('Palabra(s) clave. Ej: "absence quota", "expense", "schedule".'),
|
|
516
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
517
|
+
},
|
|
518
|
+
annotations: RO,
|
|
519
|
+
}, guard(async (a) => {
|
|
520
|
+
const hits = searchOps(a.keyword, a.limit ?? 40);
|
|
521
|
+
const text = hits.length
|
|
522
|
+
? hits.map((h) => `[${h.kind}] ${h.signature}${h.description ? `\n ${h.description}` : ''}`).join('\n')
|
|
523
|
+
: 'Sin coincidencias.';
|
|
524
|
+
return { content: [{ type: 'text', text }], structuredContent: { count: hits.length, operations: hits } };
|
|
525
|
+
}));
|
|
526
|
+
server.registerTool('beebole_describe_operation', {
|
|
527
|
+
title: 'Describir una operación',
|
|
528
|
+
description: 'Devuelve la firma completa de una query/mutation: argumentos, expansión de los input objects, scalars con sus valores permitidos y forma del tipo de retorno. Úsalo antes de llamar a beebole_graphql.',
|
|
529
|
+
inputSchema: { name: z.string().min(1).describe('Nombre exacto, p.ej. "addAbsenceType".') },
|
|
530
|
+
annotations: RO,
|
|
531
|
+
}, guard(async (a) => {
|
|
532
|
+
const desc = describeOp(a.name);
|
|
533
|
+
if (!desc)
|
|
534
|
+
throw new BeeboleError(`No existe la operación "${a.name}". Usa beebole_search_schema para encontrarla.`);
|
|
535
|
+
return { content: [{ type: 'text', text: desc }] };
|
|
536
|
+
}));
|
|
537
|
+
server.registerTool('beebole_graphql', {
|
|
538
|
+
title: 'Ejecutar GraphQL crudo',
|
|
539
|
+
description: 'Ejecuta una query o mutation GraphQL arbitraria contra Beebole (cobertura total de la API). Pasa el documento en `query` y opcionalmente `variables`. Para operaciones sin tool curada, descúbrelas con beebole_search_schema + beebole_describe_operation. Puede modificar datos: úsalo con cuidado.',
|
|
540
|
+
inputSchema: {
|
|
541
|
+
query: z.string().min(1).describe('Documento GraphQL (query o mutation, con sus variables declaradas).'),
|
|
542
|
+
variables: z.record(z.string(), z.unknown()).optional(),
|
|
543
|
+
},
|
|
544
|
+
annotations: DESTR,
|
|
545
|
+
}, guard(async (a) => {
|
|
546
|
+
return ok(await beebole.graphql(a.query, a.variables ?? {}));
|
|
547
|
+
}));
|
|
548
|
+
return server;
|
|
549
|
+
}
|
|
550
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
551
|
+
/**
|
|
552
|
+
* Aplica los cambios indicados a un time record usando las mutaciones fine-grained
|
|
553
|
+
* (una por campo) en UN solo documento con alias. Devuelve el record final, o null
|
|
554
|
+
* si no había nada que cambiar.
|
|
555
|
+
*/
|
|
556
|
+
async function applyTimeRecordEdits(beebole, id, fields) {
|
|
557
|
+
const map = [
|
|
558
|
+
{ key: 'comment', mut: 'editTimeRecordComment', arg: 'comment', type: 'String!' },
|
|
559
|
+
{ key: 'durationMinutes', mut: 'editTimeRecordDuration', arg: 'duration', type: 'Int!' },
|
|
560
|
+
{ key: 'startTime', mut: 'editTimeRecordStartTime', arg: 'startTime', type: 'BeeboleTimestamp' },
|
|
561
|
+
{ key: 'endTime', mut: 'editTimeRecordEndTime', arg: 'endTime', type: 'BeeboleTimestamp' },
|
|
562
|
+
{ key: 'taskId', mut: 'editTimeRecordTask', arg: 'taskId', type: 'BeeboleId!' },
|
|
563
|
+
{ key: 'projectIds', mut: 'editTimeRecordProjects', arg: 'projectIds', type: '[BeeboleId]!' },
|
|
564
|
+
{ key: 'nonBillable', mut: 'editTimeRecordNonBillable', arg: 'nonBillable', type: 'Boolean!' },
|
|
565
|
+
{ key: 'wfh', mut: 'editTimeRecordWfh', arg: 'wfh', type: 'Boolean!' },
|
|
566
|
+
];
|
|
567
|
+
const active = map.filter((m) => fields[m.key] !== undefined);
|
|
568
|
+
if (active.length === 0)
|
|
569
|
+
return null;
|
|
570
|
+
const decls = ['$id: BeeboleId!', ...active.map((m) => `$${m.arg}: ${m.type}`)].join(', ');
|
|
571
|
+
const calls = active.map((m, i) => `a${i}: ${m.mut}(id: $id, ${m.arg}: $${m.arg}){ ${SEL_TR()} }`).join('\n');
|
|
572
|
+
const variables = { id };
|
|
573
|
+
for (const m of active)
|
|
574
|
+
variables[m.arg] = m.key === 'durationMinutes' ? fields.durationMinutes : fields[m.key];
|
|
575
|
+
const q = `mutation(${decls}){\n${calls}\n}`;
|
|
576
|
+
const data = await beebole.graphql(q, variables);
|
|
577
|
+
return data[`a${active.length - 1}`] ?? data;
|
|
578
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neonexai/beebole-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server para Beebole (control de horas) — NeoNexAI. API GraphQL nueva (app.beebole.com): ~24 tools curadas (proyectos, tareas, personas, fichaje de horas, timesheets, informes) + passthrough GraphQL (search/describe/graphql) para cobertura total (87 queries + 745 mutations). Transportes: stdio (local) + Streamable HTTP (remoto).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"beebole-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"schema.json"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"start": "node dist/index.js",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"smoke": "node dist/smoke.js",
|
|
18
|
+
"prepare": "npm run build",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
23
|
+
"express": "^5.1.0",
|
|
24
|
+
"zod": "^3.25.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/express": "^5.0.0",
|
|
28
|
+
"@types/node": "^22.10.0",
|
|
29
|
+
"typescript": "^5.7.0"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"license": "UNLICENSED",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/NeoNexAI/beebole-mcp.git"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|