boosterarena-mcp 0.1.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/LEEME.md +109 -0
- package/cli.mjs +63 -0
- package/dist/nucleo.mjs +2688 -0
- package/encargos.mjs +117 -0
- package/package.json +37 -0
- package/proyectos.mjs +140 -0
- package/servidor.mjs +364 -0
- package/sesion.mjs +245 -0
- package/subidas.mjs +84 -0
package/encargos.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// LOS ENCARGOS, REMATADOS SIN NAVEGADOR (petición D del equipo de Planificatron).
|
|
3
|
+
//
|
|
4
|
+
// Tres boosters no se «ponen»: se USAN encima de algo. El maestro coge la moneda y la suelta
|
|
5
|
+
// sobre la imagen. El ejecutor hace la mitad que cabe en el documento —crear la moneda y
|
|
6
|
+
// centrarla— y deja un ENCARGO (`pedirSoltar`) para que alguien remate. En el navegador lo
|
|
7
|
+
// remata el editor, con su bicho comiendo y su lluvia de estrellas. Aquí no hay nada de eso
|
|
8
|
+
// que enseñar, así que se hace directamente lo que queda en el documento:
|
|
9
|
+
//
|
|
10
|
+
// · quitafondo → llamada de red al recortador, y el `src` del objetivo cambia.
|
|
11
|
+
// · magichat → una bandera: `magic: true`. La animación no es parte del documento.
|
|
12
|
+
// · transformar → una lista de imágenes en `morph`. Las trae quien llama.
|
|
13
|
+
// · trayectoria → NO se puede: hace falta el trazo del dedo. Se devuelve sin resolver, que
|
|
14
|
+
// es lo que ya hacía, y el agente se lo dice al maestro.
|
|
15
|
+
//
|
|
16
|
+
// ⚠️ EL RECORTE SE SUBE A R2, NO SE INCRUSTA. El recortador devuelve un PNG y es tentador
|
|
17
|
+
// meterlo como data-URL en el documento. Ya pasó: proyectos con PNG de 2 MB dentro, que hay
|
|
18
|
+
// que descargar enteros cada vez que se abre el slide. Se arregló en el editor (`bd02c94`) y
|
|
19
|
+
// aquí se hace igual desde el primer día — un camino nuevo no puede reintroducir un fallo que
|
|
20
|
+
// el otro ya pagó.
|
|
21
|
+
//
|
|
22
|
+
// ⚠️ Y LA MONEDA SE CONSUME. En el editor la moneda desaparece al soltarla: hizo su trabajo y
|
|
23
|
+
// no va al Player. Si se quedara, el maestro proyectaría un camaleón encima de su lámina.
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
const RECORTE = 'https://boosterarena.com/api/quitar-fondo';
|
|
26
|
+
const SUBIDA = 'https://boosterarena.com/api/upload';
|
|
27
|
+
|
|
28
|
+
/** Quita la moneda del slide: hizo lo suyo y no debe llegar a la proyección. */
|
|
29
|
+
function consumirMoneda(doc, monedaId) {
|
|
30
|
+
for (const esc of doc) {
|
|
31
|
+
for (const sl of esc.slides) {
|
|
32
|
+
sl.base = sl.base.filter((e) => e.id !== monedaId);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buscarEnDoc(doc, id) {
|
|
38
|
+
for (const esc of doc) {
|
|
39
|
+
for (const sl of esc.slides) {
|
|
40
|
+
const e = sl.base.find((x) => x.id === id);
|
|
41
|
+
if (e) return e;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Remata el encargo que dejó `aplicar_booster`. Devuelve lo que hay que contarle al agente.
|
|
49
|
+
* Si no hay encargo, devuelve cadena vacía y no toca nada.
|
|
50
|
+
*/
|
|
51
|
+
export async function rematarEncargo(borrador, token) {
|
|
52
|
+
const s = borrador.pedirSoltar;
|
|
53
|
+
if (!s) return '';
|
|
54
|
+
borrador.pedirSoltar = undefined; // se atiende una vez
|
|
55
|
+
|
|
56
|
+
const objetivo = buscarEnDoc(borrador.doc, s.objetivo.id);
|
|
57
|
+
if (!objetivo) return '\n⚠️ El objeto sobre el que ibas a aplicar la moneda ya no está.';
|
|
58
|
+
|
|
59
|
+
if (s.tipo === 'magichat') {
|
|
60
|
+
objetivo.magic = true;
|
|
61
|
+
consumirMoneda(borrador.doc, s.moneda.id);
|
|
62
|
+
return '\nHecho: la imagen queda en SILUETA negra y se revela a color cuando la toquen en el '
|
|
63
|
+
+ '▶ Player. La moneda del sombrero se consumió, como en el editor.';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (s.tipo === 'transformar') {
|
|
67
|
+
// El Transformador necesita EN QUÉ se convierte, y eso no lo puede adivinar nadie. En el
|
|
68
|
+
// editor lo elige el maestro en un diálogo; aquí lo tiene que traer quien llama.
|
|
69
|
+
consumirMoneda(borrador.doc, s.moneda.id);
|
|
70
|
+
return '\n⚠️ El Transformador necesita saber en qué se convierte, y eso no se puede adivinar. '
|
|
71
|
+
+ 'Usa `transformar_en` con las URLs de las imágenes por las que va pasando (súbelas antes '
|
|
72
|
+
+ 'con `subir_imagen` si están en el disco).';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (s.tipo === 'quitafondo') {
|
|
76
|
+
if (!objetivo.src) return '\n⚠️ Esa imagen no tiene archivo, así que no hay fondo que quitar.';
|
|
77
|
+
const r = await fetch(RECORTE, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ url: objetivo.src }),
|
|
81
|
+
});
|
|
82
|
+
if (!r.ok) {
|
|
83
|
+
// El servidor contesta con un motivo concreto. Traducirlo importa: «PRO_REQUIRED» y
|
|
84
|
+
// «se acabó la cuota» piden cosas distintas del agente.
|
|
85
|
+
const info = await r.json().catch(() => ({}));
|
|
86
|
+
const motivo = info.error;
|
|
87
|
+
const dice = motivo === 'PRO_REQUIRED'
|
|
88
|
+
? 'El recorte de máxima calidad es del plan Pro y esta cuenta no lo tiene. Díselo a quien '
|
|
89
|
+
+ 'te lo pidió: se activa desde boosterarena.com.'
|
|
90
|
+
: motivo === 'MONTHLY_LIMIT' ? 'Se acabaron los recortes de este mes en esta cuenta.'
|
|
91
|
+
: motivo === 'NO_AUTH' ? 'La sesión ya no vale. Corre `node mcp/sesion.mjs entrar`.'
|
|
92
|
+
: info.detail || `El recortador contestó ${r.status}.`;
|
|
93
|
+
consumirMoneda(borrador.doc, s.moneda.id);
|
|
94
|
+
return `\n⚠️ No se pudo quitar el fondo: ${dice} La imagen se queda como estaba.`;
|
|
95
|
+
}
|
|
96
|
+
const png = Buffer.from(await r.arrayBuffer());
|
|
97
|
+
// A R2, no al documento. Ver la cabecera.
|
|
98
|
+
const sub = await fetch(SUBIDA, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'image/png', 'X-Ext': 'png' },
|
|
101
|
+
body: png,
|
|
102
|
+
});
|
|
103
|
+
if (!sub.ok) {
|
|
104
|
+
consumirMoneda(borrador.doc, s.moneda.id);
|
|
105
|
+
return '\n⚠️ El recorte salió bien pero no se pudo guardar en la nube. La imagen se queda '
|
|
106
|
+
+ 'como estaba; vuelve a intentarlo.';
|
|
107
|
+
}
|
|
108
|
+
const { url } = await sub.json();
|
|
109
|
+
objetivo.src = url;
|
|
110
|
+
consumirMoneda(borrador.doc, s.moneda.id);
|
|
111
|
+
return `\nHecho: fondo quitado (${Math.round(png.length / 1024)} KB). La imagen ahora es el `
|
|
112
|
+
+ 'recorte, guardado en la nube. La moneda del camaleón se consumió, como en el editor.';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
consumirMoneda(borrador.doc, s.moneda.id);
|
|
116
|
+
return `\n⚠️ No sé rematar la moneda "${s.tipo}" sin navegador.`;
|
|
117
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "boosterarena-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "El MCP de Booster: tu agente crea y edita presentaciones dentro de boosterarena.com",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"booster",
|
|
8
|
+
"boosterarena",
|
|
9
|
+
"presentaciones",
|
|
10
|
+
"educacion"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://boosterarena.com",
|
|
13
|
+
"license": "UNLICENSED",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"boosterarena-mcp": "cli.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"cli.mjs",
|
|
20
|
+
"servidor.mjs",
|
|
21
|
+
"sesion.mjs",
|
|
22
|
+
"proyectos.mjs",
|
|
23
|
+
"subidas.mjs",
|
|
24
|
+
"encargos.mjs",
|
|
25
|
+
"dist/nucleo.mjs",
|
|
26
|
+
"LEEME.md"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@supabase/supabase-js": "^2.108.2"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"prepack": "node construir.mjs"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/proyectos.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// LOS PROYECTOS, DESDE FUERA DEL NAVEGADOR.
|
|
3
|
+
//
|
|
4
|
+
// Un proyecto de Booster es una fila con una columna `jsonb`: el documento es `Scene[]`, que
|
|
5
|
+
// es EXACTAMENTE lo que come y devuelve `robot/ejecutor.ts`. Por eso el MCP no necesita
|
|
6
|
+
// ningún formato intermedio — abre, ejecuta la tanda, guarda.
|
|
7
|
+
//
|
|
8
|
+
// TODO PASA POR EL REST DE SUPABASE CON EL TOKEN DEL MAESTRO. No hay `service_role` por aquí
|
|
9
|
+
// a propósito: el RLS es el que decide qué ve y qué toca cada uno, y es el MISMO que ya
|
|
10
|
+
// protege al navegador. Un agente no puede llegar más lejos que la persona que lo abrió.
|
|
11
|
+
//
|
|
12
|
+
// ⚠️ GUARDAR ES PARCIAL, NUNCA UN UPSERT ENTERO. Se mandan solo `document` y `slide_count`.
|
|
13
|
+
// El editor puede estar autoguardando el mismo proyecto en otra pestaña: un upsert completo
|
|
14
|
+
// desde aquí le pisaría el título, la miniatura o el equipo. Es la misma disciplina que ya
|
|
15
|
+
// siguen `saveThumb` y `renameProject` en `src/projects/store.ts`, y por la misma razón.
|
|
16
|
+
//
|
|
17
|
+
// node mcp/proyectos.mjs listar
|
|
18
|
+
// node mcp/proyectos.mjs abrir <id>
|
|
19
|
+
// node mcp/proyectos.mjs crear "Título de la clase"
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
import { tokenDeAcceso } from './sesion.mjs';
|
|
23
|
+
|
|
24
|
+
const SUPABASE_URL = 'https://kjoadzhtgbrngpppbysh.supabase.co';
|
|
25
|
+
const SUPABASE_KEY = 'sb_publishable_Zkvuw-xVLuns3msI14xH0g_YG5SYo7G';
|
|
26
|
+
const REST = `${SUPABASE_URL}/rest/v1`;
|
|
27
|
+
|
|
28
|
+
/** Las columnas de la LISTA. Sin `document` a posta: un curso entero son megas, y para elegir
|
|
29
|
+
* proyecto no hace falta ni una escena. La lista de la galería hace lo mismo. */
|
|
30
|
+
const COLUMNAS_LISTA = 'id,type,title,slide_count,updated_at,folder_id,team_id';
|
|
31
|
+
|
|
32
|
+
async function pedir(token, ruta, opciones = {}) {
|
|
33
|
+
const r = await fetch(`${REST}/${ruta}`, {
|
|
34
|
+
...opciones,
|
|
35
|
+
headers: {
|
|
36
|
+
apikey: SUPABASE_KEY,
|
|
37
|
+
Authorization: `Bearer ${token}`,
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
...(opciones.headers ?? {}),
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
if (!r.ok) {
|
|
43
|
+
const detalle = await r.text();
|
|
44
|
+
// El 401 tiene una causa concreta y una salida concreta: no vale con «falló».
|
|
45
|
+
if (r.status === 401) throw new Error('La sesión ya no vale. Vuelve a entrar: `sesion.mjs entrar`.');
|
|
46
|
+
throw new Error(`Supabase contestó ${r.status}: ${detalle.slice(0, 300)}`);
|
|
47
|
+
}
|
|
48
|
+
return r.status === 204 ? null : r.json();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Los proyectos del maestro, del más reciente al más viejo. Sin el documento. */
|
|
52
|
+
export async function listar(token, { tipo, limite = 50 } = {}) {
|
|
53
|
+
const q = new URLSearchParams({ select: COLUMNAS_LISTA, order: 'updated_at.desc', limit: String(limite) });
|
|
54
|
+
if (tipo) q.set('type', `eq.${tipo}`);
|
|
55
|
+
return pedir(token, `projects?${q}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Un proyecto ENTERO, con su documento. Es lo que el ejecutor va a modificar. */
|
|
59
|
+
export async function abrir(token, id) {
|
|
60
|
+
const filas = await pedir(token, `projects?id=eq.${encodeURIComponent(id)}&select=*&limit=1`);
|
|
61
|
+
if (!filas.length) throw new Error(`No hay ningún proyecto con id ${id} en esta cuenta.`);
|
|
62
|
+
return filas[0];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Guarda el documento. Update PARCIAL: solo `document` y `slide_count`.
|
|
67
|
+
* `escenas` es el `Scene[]` que devuelve el ejecutor.
|
|
68
|
+
*/
|
|
69
|
+
export async function guardar(token, id, escenas) {
|
|
70
|
+
if (!Array.isArray(escenas)) throw new Error('El documento tiene que ser una lista de escenas (Scene[]).');
|
|
71
|
+
const slides = escenas.reduce((n, e) => n + (e.slides?.length ?? 0), 0);
|
|
72
|
+
await pedir(token, `projects?id=eq.${encodeURIComponent(id)}`, {
|
|
73
|
+
method: 'PATCH',
|
|
74
|
+
headers: { Prefer: 'return=minimal' },
|
|
75
|
+
body: JSON.stringify({ document: escenas, slide_count: slides }),
|
|
76
|
+
});
|
|
77
|
+
return { slides };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Crea un proyecto nuevo. `team_id` va sin poner a propósito: hay un trigger
|
|
82
|
+
* (`projects_default_team`) que lo manda al equipo personal del maestro, que es lo que
|
|
83
|
+
* queremos por defecto.
|
|
84
|
+
*/
|
|
85
|
+
export async function crear(token, titulo, tipo = 'presentation') {
|
|
86
|
+
const escenaVacia = [{ id: `sc-${Date.now().toString(36)}`, name: 'Escena 1', slides: [{ id: `sl-${Date.now().toString(36)}`, base: [], layers: [] }] }];
|
|
87
|
+
const filas = await pedir(token, 'projects?select=*', {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { Prefer: 'return=representation' },
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
type: tipo,
|
|
92
|
+
title: titulo?.trim() || 'Presentación sin título',
|
|
93
|
+
slide_count: 1,
|
|
94
|
+
document: tipo === 'presentation' ? escenaVacia : null,
|
|
95
|
+
assets: [],
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
return filas[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Cara de línea de comandos (para probar a mano) ───────────────────────────
|
|
102
|
+
// `pathToFileURL` y no una plantilla a mano: en Windows la URL lleva TRES barras
|
|
103
|
+
// (`file:///C:/...`), y compararla con dos hacía que el comando se quedara MUDO, sin error.
|
|
104
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
105
|
+
const YO = `node ${process.argv[1]}`;
|
|
106
|
+
const [, , orden, ...resto] = process.argv;
|
|
107
|
+
const token = await tokenDeAcceso();
|
|
108
|
+
if (!token) {
|
|
109
|
+
console.error('\n ✕ No hay sesión. Entra primero con `sesion.mjs entrar`.\n');
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
if (orden === 'listar') {
|
|
114
|
+
const ps = await listar(token);
|
|
115
|
+
console.log(`\n ${ps.length} proyecto(s):\n`);
|
|
116
|
+
for (const p of ps) {
|
|
117
|
+
const fecha = new Date(p.updated_at).toISOString().slice(0, 16).replace('T', ' ');
|
|
118
|
+
console.log(` ${p.id} ${String(p.slide_count).padStart(3)} slides ${fecha} ${p.title}`);
|
|
119
|
+
}
|
|
120
|
+
console.log('');
|
|
121
|
+
} else if (orden === 'abrir') {
|
|
122
|
+
const p = await abrir(token, resto[0]);
|
|
123
|
+
const escenas = Array.isArray(p.document) ? p.document : [];
|
|
124
|
+
console.log(`\n «${p.title}» · ${p.type} · ${escenas.length} escena(s)\n`);
|
|
125
|
+
for (const e of escenas) console.log(` ${e.name ?? '(sin nombre)'} — ${e.slides?.length ?? 0} slide(s)`);
|
|
126
|
+
console.log('');
|
|
127
|
+
} else if (orden === 'crear') {
|
|
128
|
+
const p = await crear(token, resto.join(' '));
|
|
129
|
+
console.log(`\n ✓ Creado «${p.title}»\n id: ${p.id}\n`);
|
|
130
|
+
} else {
|
|
131
|
+
console.log('\n Proyectos de Booster desde la terminal\n');
|
|
132
|
+
console.log(` ${YO} listar`);
|
|
133
|
+
console.log(` ${YO} abrir <id>`);
|
|
134
|
+
console.log(` ${YO} crear "Título de la clase"\n`);
|
|
135
|
+
}
|
|
136
|
+
} catch (e) {
|
|
137
|
+
console.error(`\n ✕ ${e.message}\n`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
}
|
package/servidor.mjs
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// EL SERVIDOR MCP DE BOOSTER.
|
|
3
|
+
//
|
|
4
|
+
// Esto es lo que convierte todo lo demás en algo que un agente puede usar. Hasta ahora las
|
|
5
|
+
// piezas estaban —la puerta, los proyectos, el ejecutor puro, las fábricas, el registro— pero
|
|
6
|
+
// nadie de fuera podía hablar con ellas: hacía falta un script que importara los módulos.
|
|
7
|
+
//
|
|
8
|
+
// CÓMO SE CONECTA UN AGENTE. Por la entrada y la salida estándar, con JSON-RPC 2.0 en líneas
|
|
9
|
+
// sueltas. Se declara en el `mcp.json` del cliente:
|
|
10
|
+
//
|
|
11
|
+
// { "mcpServers": { "booster": { "command": "node",
|
|
12
|
+
// "args": ["<ruta>/mcp/servidor.mjs"] } } }
|
|
13
|
+
//
|
|
14
|
+
// ⚠️ LA SALIDA ESTÁNDAR ES DEL PROTOCOLO. Un `console.log` suelto aquí corrompe el canal y el
|
|
15
|
+
// cliente se desconecta sin decir por qué. TODO lo que se quiera contar va por `stderr`
|
|
16
|
+
// (`aviso()`), que el cliente enseña como registro. Es el error clásico de un servidor MCP y
|
|
17
|
+
// se paga con una hora de no entender nada.
|
|
18
|
+
//
|
|
19
|
+
// QUÉ NO HACE FALTA HOSPEDAR. Nada. Esto corre en la máquina del maestro y habla con las
|
|
20
|
+
// mismas funciones de `/api` que usa el navegador, con su token. El candado del plan ya vive
|
|
21
|
+
// allí, así que un agente no puede llegar más lejos que la persona que lo abrió.
|
|
22
|
+
//
|
|
23
|
+
// node mcp/sesion.mjs entrar ← primero hay que tener sesión
|
|
24
|
+
// node mcp/servidor.mjs ← esto lo arranca el cliente, no tú
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
import { createInterface } from 'node:readline';
|
|
27
|
+
import { unlinkSync } from 'node:fs';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
32
|
+
import { tokenDeAcceso } from './sesion.mjs';
|
|
33
|
+
import { abrir, crear, guardar, listar } from './proyectos.mjs';
|
|
34
|
+
import { subirArchivo } from './subidas.mjs';
|
|
35
|
+
import { rematarEncargo } from './encargos.mjs';
|
|
36
|
+
|
|
37
|
+
const aviso = (...a) => process.stderr.write(`[booster-mcp] ${a.join(' ')}\n`);
|
|
38
|
+
|
|
39
|
+
// Los mismos valores públicos que el navegador (ver `mcp/sesion.mjs`).
|
|
40
|
+
const SUPABASE_URL = 'https://kjoadzhtgbrngpppbysh.supabase.co';
|
|
41
|
+
const SUPABASE_KEY = 'sb_publishable_Zkvuw-xVLuns3msI14xH0g_YG5SYo7G';
|
|
42
|
+
|
|
43
|
+
// ── El núcleo ────────────────────────────────────────────────────────────────
|
|
44
|
+
// El núcleo está en TypeScript y Node no lo carga solo, así que hay DOS caminos y se prefiere
|
|
45
|
+
// el primero:
|
|
46
|
+
//
|
|
47
|
+
// 1. `dist/nucleo.mjs` — lo deja `construir.mjs` antes de publicar. El paquete que instala
|
|
48
|
+
// Planificatron trae esto y NO necesita esbuild ni el repo: arranca y ya.
|
|
49
|
+
// 2. esbuild al vuelo sobre `src/nucleo/mcp.ts` — solo cuando se corre desde el repo, para
|
|
50
|
+
// que el servidor use SIEMPRE el código vivo y no una copia que se quede vieja.
|
|
51
|
+
//
|
|
52
|
+
// ⚠️ LA RUTA SE CALCULA DESDE ESTE ARCHIVO, NO DESDE EL DIRECTORIO ACTUAL. Antes decía
|
|
53
|
+
// `entryPoints: ['src/nucleo/mcp.ts']`, que es relativo al cwd: el cliente MCP arranca el
|
|
54
|
+
// servidor desde donde le da la gana y aquello solo funcionaba si te habías puesto en la raíz
|
|
55
|
+
// del proyecto. Un fallo que en el repo no se nota nunca y en casa ajena no arranca jamás.
|
|
56
|
+
//
|
|
57
|
+
// ⚠️ UNA SOLA ENTRADA (`src/nucleo/mcp.ts`), NO UNA POR MÓDULO. Empaquetar cada uno por
|
|
58
|
+
// separado le da a cada paquete su propia copia de lo compartido: se llamaba a
|
|
59
|
+
// `cargarBiblioteca()` en una y `poner_dibujo` buscaba en otra, vacía. Contestaba «no hay nada
|
|
60
|
+
// parecido a "torre Eiffel"» con el dibujo delante. Ver la cabecera de `src/nucleo/mcp.ts`.
|
|
61
|
+
const AQUI = fileURLToPath(new URL('.', import.meta.url));
|
|
62
|
+
const RAIZ = join(AQUI, '..');
|
|
63
|
+
const YA_HECHO = join(AQUI, 'dist', 'nucleo.mjs');
|
|
64
|
+
|
|
65
|
+
let nucleo;
|
|
66
|
+
if (existsSync(YA_HECHO)) {
|
|
67
|
+
nucleo = await import(pathToFileURL(YA_HECHO).href);
|
|
68
|
+
aviso('núcleo cargado (compilado)');
|
|
69
|
+
} else {
|
|
70
|
+
const esbuild = await import('esbuild').catch(() => {
|
|
71
|
+
throw new Error('No encuentro ni `mcp/dist/nucleo.mjs` ni esbuild. Si estás en el repo, '
|
|
72
|
+
+ 'corre `npm install`; si esto es el paquete instalado, está incompleto y hay que '
|
|
73
|
+
+ 'reinstalarlo.');
|
|
74
|
+
});
|
|
75
|
+
const salida = join(tmpdir(), `booster-mcp-nucleo-${process.pid}.mjs`);
|
|
76
|
+
await esbuild.build({
|
|
77
|
+
entryPoints: [join(RAIZ, 'src/nucleo/mcp.ts')], bundle: true, format: 'esm',
|
|
78
|
+
platform: 'neutral', outfile: salida, logLevel: 'silent',
|
|
79
|
+
});
|
|
80
|
+
nucleo = await import(pathToFileURL(salida).href);
|
|
81
|
+
try { unlinkSync(salida); } catch { /* da igual */ }
|
|
82
|
+
aviso('núcleo cargado (del repo, al vuelo)');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── El estado de la sesión ───────────────────────────────────────────────────
|
|
86
|
+
// Un servidor por cliente, así que el estado vive aquí: qué proyecto está abierto y el
|
|
87
|
+
// borrador sobre el que se trabaja. `guardar_proyecto` lo sube y ya.
|
|
88
|
+
const yo = { proyecto: null, borrador: null, plan: 'desconocido' };
|
|
89
|
+
|
|
90
|
+
let idSeq = 0;
|
|
91
|
+
const manos = () => ({
|
|
92
|
+
premium: yo.plan === 'pro',
|
|
93
|
+
uid: (p) => `mcp${p}-${Date.now().toString(36)}-${(idSeq++).toString(36)}`,
|
|
94
|
+
// Cascada tonta: lo justo para que dos boosters seguidos no caigan exactamente encima.
|
|
95
|
+
nextPos: (x, y) => ({ x: Math.min(92, x + (idSeq % 5) * 2.5), y: Math.min(84, y + (idSeq % 5) * 2.5) }),
|
|
96
|
+
});
|
|
97
|
+
const fabricas = () => ({
|
|
98
|
+
nuevoWidget: (t) => nucleo.construirWidget(t, manos()),
|
|
99
|
+
nuevaArena: (t) => nucleo.construirArena(t, manos()),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
async function token() {
|
|
103
|
+
const t = await tokenDeAcceso();
|
|
104
|
+
if (!t) throw new Error('No hay sesión en este equipo. Corre `node mcp/sesion.mjs entrar` y vuelve a arrancar el agente.');
|
|
105
|
+
return t;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* El plan del maestro, de la MISMA tabla que lee el navegador (`profiles.plan`, ver
|
|
110
|
+
* `src/lib/billing.ts`). Una sola fuente a propósito: si el MCP mirara otro sitio, un día
|
|
111
|
+
* diría «Pro» donde el editor dice «free» y nadie entendería por qué.
|
|
112
|
+
*
|
|
113
|
+
* Sin fila o sin poder leerla ⇒ `free`. Equivocarse hacia abajo cuesta un aviso; equivocarse
|
|
114
|
+
* hacia arriba regala lo de pago.
|
|
115
|
+
*/
|
|
116
|
+
async function mirarPlan(t) {
|
|
117
|
+
try {
|
|
118
|
+
const r = await fetch(`${SUPABASE_URL}/rest/v1/profiles?select=plan&limit=1`, {
|
|
119
|
+
headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${t}` },
|
|
120
|
+
});
|
|
121
|
+
const filas = r.ok ? await r.json() : [];
|
|
122
|
+
yo.plan = filas[0]?.plan === 'pro' ? 'pro' : 'free';
|
|
123
|
+
} catch { yo.plan = 'free'; }
|
|
124
|
+
aviso(`plan de la cuenta: ${yo.plan}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Las herramientas ─────────────────────────────────────────────────────────
|
|
128
|
+
// Las del lienzo salen del MISMO catálogo que ve el Robot en el navegador: si el editor
|
|
129
|
+
// aprende algo nuevo, el MCP lo sabe usar sin tocar este archivo. Solo cambia el envoltorio
|
|
130
|
+
// (`parameters` de OpenAI → `inputSchema` de MCP), que resultan ser el mismo JSON Schema.
|
|
131
|
+
function herramientasDelLienzo() {
|
|
132
|
+
return nucleo.construirCatalogo({
|
|
133
|
+
boosters: nucleo.ALL_WIDGETS.filter((w) => w.ready).map((w) => w.type),
|
|
134
|
+
arenas: nucleo.ARENAS.filter((a) => a.ready).map((a) => a.type),
|
|
135
|
+
formas: Object.keys(nucleo.FORMA_DEF),
|
|
136
|
+
}).map((t) => ({
|
|
137
|
+
name: t.function.name,
|
|
138
|
+
description: t.function.description,
|
|
139
|
+
inputSchema: t.function.parameters,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const HERRAMIENTAS_PROYECTO = [
|
|
144
|
+
{
|
|
145
|
+
name: 'listar_proyectos',
|
|
146
|
+
description: 'Los proyectos de Booster del maestro, del más reciente al más viejo. Devuelve id, '
|
|
147
|
+
+ 'título y cuántos slides tiene. Úsalo para saber sobre cuál trabajar.',
|
|
148
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'abrir_proyecto',
|
|
152
|
+
description: 'Abre un proyecto y lo deja LISTO para trabajar. A partir de aquí, todo lo que pongas '
|
|
153
|
+
+ 'cae en él. Hay que llamarlo antes que cualquier herramienta del lienzo.',
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: 'object',
|
|
156
|
+
properties: { id: { type: 'string', description: 'El id que devolvió `listar_proyectos`.' } },
|
|
157
|
+
required: ['id'],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'crear_proyecto',
|
|
162
|
+
description: 'Crea una presentación nueva, vacía, y la deja abierta para trabajar.',
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: { titulo: { type: 'string', description: 'Cómo se va a llamar. Ej: "A2 Kids · Topic 9 · Clase 2".' } },
|
|
166
|
+
required: ['titulo'],
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'guardar_proyecto',
|
|
171
|
+
description: '⚠️ SUBE A LA NUBE lo que llevas hecho. Hasta que llames a esto, nada de lo que has '
|
|
172
|
+
+ 'puesto existe fuera de esta conversación. Llámalo al terminar, y también de vez en cuando '
|
|
173
|
+
+ 'si la tanda es larga.',
|
|
174
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'subir_imagen',
|
|
178
|
+
description: 'Sube una imagen de ESTA computadora a la nube de Booster y devuelve su URL. Es '
|
|
179
|
+
+ 'para lo que genera tu fábrica (Flux, un render, una foto). Después ponla en el slide con '
|
|
180
|
+
+ '`poner_imagen` usando la URL que devuelve. ⚠️ Conviértela a WebP antes: eso se le '
|
|
181
|
+
+ 'descarga al alumno.',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: { ruta: { type: 'string', description: 'Ruta completa del archivo en esta computadora.' } },
|
|
185
|
+
required: ['ruta'],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'subir_audio',
|
|
190
|
+
description: 'Sube un audio de ESTA computadora a la nube de Booster y devuelve su URL. Es para '
|
|
191
|
+
+ 'lo que genera tu fábrica (un TTS, una grabación). Después ponlo con `poner_audio`. '
|
|
192
|
+
+ 'Booster NO genera voz: la trae quien llama.',
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: 'object',
|
|
195
|
+
properties: { ruta: { type: 'string', description: 'Ruta completa del archivo en esta computadora.' } },
|
|
196
|
+
required: ['ruta'],
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'transformar_en',
|
|
201
|
+
description: 'Hace que un objeto se CONVIERTA en otras imágenes al hacerle clic en el ▶ Player '
|
|
202
|
+
+ '(de la oruga a la mariposa); la última lo devuelve a como estaba. Es el remate de '
|
|
203
|
+
+ '`aplicar_booster` con "transformar", que por sí solo no puede saber en qué se convierte. '
|
|
204
|
+
+ 'Las imágenes tienen que estar ya subidas: usa `subir_imagen` si están en el disco.',
|
|
205
|
+
inputSchema: {
|
|
206
|
+
type: 'object',
|
|
207
|
+
properties: {
|
|
208
|
+
objetivo: { type: 'string', description: 'El `ref` o el id del objeto que se transforma.' },
|
|
209
|
+
urls: {
|
|
210
|
+
type: 'array', items: { type: 'string' },
|
|
211
|
+
description: 'Las imágenes por las que va pasando, en orden. De 1 a 12.',
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
required: ['objetivo', 'urls'],
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: 'ver_slide',
|
|
219
|
+
description: 'Cuenta qué hay ahora mismo en el proyecto abierto: sus escenas, sus slides y lo que '
|
|
220
|
+
+ 'tiene cada uno. Úsalo para orientarte antes de tocar algo que ya existe.',
|
|
221
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
222
|
+
},
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
// ── Ejecutar una herramienta ─────────────────────────────────────────────────
|
|
226
|
+
function exigeProyecto() {
|
|
227
|
+
if (!yo.borrador) {
|
|
228
|
+
throw new Error('No hay ningún proyecto abierto. Llama antes a `abrir_proyecto` (o a '
|
|
229
|
+
+ '`crear_proyecto` si es una clase nueva).');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function resumenDelDoc(doc) {
|
|
234
|
+
const filas = [];
|
|
235
|
+
doc.forEach((esc, i) => {
|
|
236
|
+
filas.push(`Escena ${i + 1}: «${esc.name ?? 'sin nombre'}» — ${esc.slides.length} slide(s)`);
|
|
237
|
+
esc.slides.forEach((s, j) => {
|
|
238
|
+
const qué = s.base.map((e) => (
|
|
239
|
+
e.kind === 'text' ? `texto «${String(e.text ?? '').slice(0, 26)}»`
|
|
240
|
+
: e.kind === 'arena' ? `ARENA ${e.arena}`
|
|
241
|
+
: e.kind === 'widget' ? `booster ${e.widget}`
|
|
242
|
+
: e.kind === 'image' ? 'dibujo' : e.kind));
|
|
243
|
+
filas.push(` slide ${j + 1}: ${qué.join(' · ') || '(vacío)'}`);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
return filas.join('\n');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function llamar(nombre, args = {}) {
|
|
250
|
+
// ── Proyectos ──────────────────────────────────────────────────────────────
|
|
251
|
+
if (nombre === 'listar_proyectos') {
|
|
252
|
+
const t = await token();
|
|
253
|
+
const ps = await listar(t, { tipo: 'presentation' });
|
|
254
|
+
return ps.map((p) => `${p.id} ${String(p.slide_count).padStart(3)} slides ${p.title}`).join('\n')
|
|
255
|
+
|| 'No hay ningún proyecto todavía. Crea uno con `crear_proyecto`.';
|
|
256
|
+
}
|
|
257
|
+
if (nombre === 'abrir_proyecto' || nombre === 'crear_proyecto') {
|
|
258
|
+
const t = await token();
|
|
259
|
+
await mirarPlan(t);
|
|
260
|
+
const p = nombre === 'crear_proyecto' ? await crear(t, args.titulo) : await abrir(t, args.id);
|
|
261
|
+
if (p.type !== 'presentation') throw new Error(`«${p.title}» es un ${p.type}, y por aquí solo se trabajan presentaciones.`);
|
|
262
|
+
yo.proyecto = p;
|
|
263
|
+
yo.borrador = nucleo.nuevoBorrador(Array.isArray(p.document) ? p.document : [], 0, 0, '');
|
|
264
|
+
await nucleo.cargarBiblioteca(); // `poner_dibujo` busca en caliente
|
|
265
|
+
return `Abierto «${p.title}» (${yo.borrador.doc.length} escena(s)). Plan de la cuenta: ${yo.plan}.\n\n${resumenDelDoc(yo.borrador.doc)}`;
|
|
266
|
+
}
|
|
267
|
+
if (nombre === 'subir_imagen' || nombre === 'subir_audio') {
|
|
268
|
+
const t = await token();
|
|
269
|
+
const r = await subirArchivo(t, args.ruta, nombre === 'subir_imagen' ? 'imagen' : 'audio');
|
|
270
|
+
return `Subido «${r.nombre}» (${r.kb} KB).
|
|
271
|
+
URL: ${r.url}
|
|
272
|
+
`
|
|
273
|
+
+ `Ahora ponlo con \`${nombre === 'subir_imagen' ? 'poner_imagen' : 'poner_audio'}\` usando esa URL.`
|
|
274
|
+
+ (r.aviso ? `
|
|
275
|
+
${r.aviso}` : '');
|
|
276
|
+
}
|
|
277
|
+
if (nombre === 'guardar_proyecto') {
|
|
278
|
+
exigeProyecto();
|
|
279
|
+
const t = await token();
|
|
280
|
+
const { slides } = await guardar(t, yo.proyecto.id, yo.borrador.doc);
|
|
281
|
+
return `Guardado en la nube: «${yo.proyecto.title}», ${slides} slide(s). Ya se puede abrir en boosterarena.com.`;
|
|
282
|
+
}
|
|
283
|
+
if (nombre === 'ver_slide') {
|
|
284
|
+
exigeProyecto();
|
|
285
|
+
return resumenDelDoc(yo.borrador.doc);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Transformar: la única moneda que necesita datos de fuera ────────────────
|
|
289
|
+
// En el editor, el maestro elige en un diálogo en qué se convierte el objeto. Aquí no hay
|
|
290
|
+
// diálogo, así que las imágenes las trae quien llama.
|
|
291
|
+
if (nombre === 'transformar_en') {
|
|
292
|
+
exigeProyecto();
|
|
293
|
+
const urls = (args.urls ?? []).filter((u) => /^https?:\/\//i.test(String(u)));
|
|
294
|
+
if (!urls.length) throw new Error('Ninguna de esas `urls` es una dirección https:// de una imagen ya subida. Súbelas antes con `subir_imagen`.');
|
|
295
|
+
if (urls.length > 12) throw new Error('Doce imágenes es el tope: una cadena más larga no hay quien la proyecte.');
|
|
296
|
+
const id = yo.borrador.alias.get(String(args.objetivo).trim()) ?? String(args.objetivo).trim();
|
|
297
|
+
let obj = null;
|
|
298
|
+
for (const esc of yo.borrador.doc) for (const sl of esc.slides) { const e = sl.base.find((x) => x.id === id); if (e) obj = e; }
|
|
299
|
+
if (!obj) throw new Error(`No encuentro ningún objeto "${args.objetivo}".`);
|
|
300
|
+
obj.morph = { srcs: urls };
|
|
301
|
+
return `Listo: al hacer clic sobre ese objeto en el ▶ Player irá pasando por ${urls.length} `
|
|
302
|
+
+ 'imagen(es) y volverá a la original.';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── El lienzo: lo hace el MISMO ejecutor que el Robot del navegador ─────────
|
|
306
|
+
exigeProyecto();
|
|
307
|
+
const dijo = nucleo.ejecutar(yo.borrador, nombre, args, fabricas());
|
|
308
|
+
// Si la herramienta dejó un ENCARGO (una de las tres monedas que se sueltan sobre algo), se
|
|
309
|
+
// remata aquí: en el navegador lo hace el editor con su animación; sin navegador, esto.
|
|
310
|
+
const remate = yo.borrador.pedirSoltar ? await rematarEncargo(yo.borrador, await token()) : '';
|
|
311
|
+
// La bitácora es lo que el editor le enseña al maestro. Aquí se devuelve también, porque
|
|
312
|
+
// para un agente es la única forma de saber qué quedó apuntado.
|
|
313
|
+
const últimas = yo.borrador.bitacora.slice(-3).join(' · ');
|
|
314
|
+
return `${dijo}${remate}${últimas ? `\n(bitácora: ${últimas})` : ''}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── El protocolo ─────────────────────────────────────────────────────────────
|
|
318
|
+
const escribir = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
319
|
+
const ok = (id, result) => escribir({ jsonrpc: '2.0', id, result });
|
|
320
|
+
const mal = (id, code, message) => escribir({ jsonrpc: '2.0', id, error: { code, message } });
|
|
321
|
+
|
|
322
|
+
async function atender(msg) {
|
|
323
|
+
const { id, method, params } = msg;
|
|
324
|
+
// Las notificaciones no llevan id y NO se contestan. Contestarlas rompe a algunos clientes.
|
|
325
|
+
if (id === undefined) return;
|
|
326
|
+
|
|
327
|
+
if (method === 'initialize') {
|
|
328
|
+
return ok(id, {
|
|
329
|
+
protocolVersion: params?.protocolVersion ?? '2024-11-05',
|
|
330
|
+
capabilities: { tools: {} },
|
|
331
|
+
serverInfo: { name: 'booster', version: '0.1.0' },
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
if (method === 'tools/list') {
|
|
335
|
+
return ok(id, { tools: [...HERRAMIENTAS_PROYECTO, ...herramientasDelLienzo()] });
|
|
336
|
+
}
|
|
337
|
+
if (method === 'tools/call') {
|
|
338
|
+
const nombre = params?.name;
|
|
339
|
+
try {
|
|
340
|
+
const texto = await llamar(nombre, params?.arguments ?? {});
|
|
341
|
+
return ok(id, { content: [{ type: 'text', text: String(texto) }] });
|
|
342
|
+
} catch (e) {
|
|
343
|
+
// Un fallo de herramienta se devuelve como RESULTADO con `isError`, no como error de
|
|
344
|
+
// protocolo: así el agente lo lee, lo entiende y puede corregir en el mismo turno, en vez
|
|
345
|
+
// de que el cliente lo trate como una caída del servidor.
|
|
346
|
+
aviso(`fallo en ${nombre}: ${e.message}`);
|
|
347
|
+
return ok(id, { content: [{ type: 'text', text: `✕ ${e.message}` }], isError: true });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return mal(id, -32601, `No conozco el método "${method}".`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
createInterface({ input: process.stdin }).on('line', (linea) => {
|
|
354
|
+
const t = linea.trim();
|
|
355
|
+
if (!t) return;
|
|
356
|
+
let msg;
|
|
357
|
+
try { msg = JSON.parse(t); } catch { return aviso('llegó una línea que no era JSON'); }
|
|
358
|
+
atender(msg).catch((e) => {
|
|
359
|
+
aviso(`error atendiendo: ${e.message}`);
|
|
360
|
+
if (msg.id !== undefined) mal(msg.id, -32603, e.message);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
aviso('listo, escuchando por stdio');
|