|
|
|
|
@@ -1,440 +0,0 @@
|
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
/**
|
|
|
|
|
* MCP server para Story Studio — permite a agentes controlar la app de series animadas IA
|
|
|
|
|
* (crear proyectos, idea motriz, personajes, capítulos, guión, escaleta, prompts, fondos,
|
|
|
|
|
* imágenes, vídeos y render) a través de su API HTTP.
|
|
|
|
|
*
|
|
|
|
|
* Transporte: stdio. Configuración: STORY_STUDIO_URL (default http://localhost:3000).
|
|
|
|
|
*
|
|
|
|
|
* NOTA: la app no tiene autenticación; el server hereda ese modelo. Apunta STORY_STUDIO_URL
|
|
|
|
|
* al entorno correcto (local o https://story-studio.carlosnarro.com).
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
|
|
|
import { z } from 'zod';
|
|
|
|
|
import { apiGet, apiPost, seg, toolResult, toolError, BASE_URL, ApiError } from './api.js';
|
|
|
|
|
|
|
|
|
|
const server = new McpServer({ name: 'story-studio-mcp-server', version: '1.0.0' });
|
|
|
|
|
|
|
|
|
|
// Anotaciones reutilizables.
|
|
|
|
|
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } as const;
|
|
|
|
|
// Operaciones con efectos (crean/generan; muchas llaman a LLM/imagen/vídeo → openWorld, no idempotentes).
|
|
|
|
|
const GEN = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true } as const;
|
|
|
|
|
|
|
|
|
|
type Args = Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
/** Envuelve el handler con try/catch → resultado de tool uniforme. */
|
|
|
|
|
function wrap(fn: (args: Args) => Promise<unknown>) {
|
|
|
|
|
return async (args: Args) => {
|
|
|
|
|
try {
|
|
|
|
|
return toolResult(await fn(args));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return toolError(e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const slugField = z.string().min(1).describe('Slug del proyecto (p.ej. "coco-y-miko"). Obtenlo de story_list_projects.');
|
|
|
|
|
const numeroField = z.number().int().min(1).describe('Número de capítulo (entero, p.ej. 1).');
|
|
|
|
|
|
|
|
|
|
// Helpers de path.
|
|
|
|
|
const capBase = (slug: string, numero: number) => `/api/projects/${seg(slug)}/capitulos/${numero}`;
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// PROYECTOS
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_list_projects', {
|
|
|
|
|
title: 'Listar proyectos',
|
|
|
|
|
description: 'Lista todos los proyectos de Story Studio (id, nombre, slug, estado, paso actual). Empieza por aquí para descubrir qué proyectos existen y su slug.',
|
|
|
|
|
inputSchema: {},
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async () => apiGet('/api/projects')));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_project', {
|
|
|
|
|
title: 'Ver proyecto',
|
|
|
|
|
description: 'Devuelve los metadatos de un proyecto por su slug.',
|
|
|
|
|
inputSchema: { slug: slugField },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug }) => apiGet(`/api/projects/${seg(slug as string)}`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_create_project', {
|
|
|
|
|
title: 'Crear proyecto',
|
|
|
|
|
description: 'Crea un proyecto nuevo a partir de un nombre. El slug se deriva del nombre. Falla si ya existe o si el nombre normaliza a vacío.',
|
|
|
|
|
inputSchema: { name: z.string().min(1).describe('Nombre del proyecto/serie.') },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ name }) => apiPost('/api/projects', { name })));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// IDEA MOTRIZ
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_idea_motriz', {
|
|
|
|
|
title: 'Ver idea motriz',
|
|
|
|
|
description: 'Devuelve la Idea Motriz del proyecto (concepto, narrativa, protagonistas, valores, estructura de guión…) o null si aún no existe.',
|
|
|
|
|
inputSchema: { slug: slugField },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug }) => apiGet(`/api/projects/${seg(slug as string)}/idea-motriz`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_idea_motriz', {
|
|
|
|
|
title: 'Generar idea motriz',
|
|
|
|
|
description: 'Genera (con GPT-4o) la Idea Motriz a partir de una descripción libre y la guarda. Sobrescribe la existente. Devuelve la Idea Motriz generada.',
|
|
|
|
|
inputSchema: {
|
|
|
|
|
slug: slugField,
|
|
|
|
|
input: z.string().min(1).describe('Descripción/brief de la serie a partir del cual generar la idea motriz.'),
|
|
|
|
|
},
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, input }) =>
|
|
|
|
|
apiPost(`/api/projects/${seg(slug as string)}/idea-motriz`, { action: 'generate', input })));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_refine_idea_motriz', {
|
|
|
|
|
title: 'Refinar idea motriz',
|
|
|
|
|
description: 'Refina la Idea Motriz existente con feedback en lenguaje natural (GPT-4o) y guarda el resultado. Requiere que ya exista una Idea Motriz.',
|
|
|
|
|
inputSchema: {
|
|
|
|
|
slug: slugField,
|
|
|
|
|
feedback: z.string().min(1).describe('Instrucciones de cambio/mejora sobre la idea motriz actual.'),
|
|
|
|
|
},
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, feedback }) => {
|
|
|
|
|
const s = slug as string;
|
|
|
|
|
const current = await apiGet(`/api/projects/${seg(s)}/idea-motriz`);
|
|
|
|
|
if (!current) throw new ApiError(400, '', 'No hay Idea Motriz que refinar. Usa story_generate_idea_motriz primero.');
|
|
|
|
|
return apiPost(`/api/projects/${seg(s)}/idea-motriz`, { action: 'refine', current, input: feedback });
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// PERSONAJES
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_personajes', {
|
|
|
|
|
title: 'Ver personajes',
|
|
|
|
|
description: 'Lista los personajes del proyecto (ficha completa: especie, apariencia, personalidad, utilería…).',
|
|
|
|
|
inputSchema: { slug: slugField },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug }) => apiGet(`/api/projects/${seg(slug as string)}/personajes`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_personajes', {
|
|
|
|
|
title: 'Generar personajes',
|
|
|
|
|
description: 'Genera el elenco de personajes (GPT-4o) a partir de la Idea Motriz del proyecto y los guarda (reemplaza los existentes). Requiere Idea Motriz creada.',
|
|
|
|
|
inputSchema: { slug: slugField },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug }) =>
|
|
|
|
|
apiPost(`/api/projects/${seg(slug as string)}/personajes`, { action: 'generate' })));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_add_personaje', {
|
|
|
|
|
title: 'Añadir personaje',
|
|
|
|
|
description: 'Genera y añade UN personaje nuevo (GPT-4o) a partir de nombre, tipo y una descripción breve, integrándolo con los existentes.',
|
|
|
|
|
inputSchema: {
|
|
|
|
|
slug: slugField,
|
|
|
|
|
nombre: z.string().min(1).describe('Nombre del personaje.'),
|
|
|
|
|
tipo: z.enum(['protagonista', 'aliado', 'antagonista']).describe('Rol del personaje.'),
|
|
|
|
|
descripcion: z.string().min(1).describe('Descripción breve para guiar la generación.'),
|
|
|
|
|
},
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, nombre, tipo, descripcion }) =>
|
|
|
|
|
apiPost(`/api/projects/${seg(slug as string)}/personajes`, {
|
|
|
|
|
action: 'add', nuevoPersonaje: { nombre, tipo, descripcion },
|
|
|
|
|
})));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_improve_personajes', {
|
|
|
|
|
title: 'Mejorar personajes',
|
|
|
|
|
description: 'Mejora TODO el elenco con feedback en lenguaje natural (GPT-4o) y guarda el resultado.',
|
|
|
|
|
inputSchema: {
|
|
|
|
|
slug: slugField,
|
|
|
|
|
feedback: z.string().min(1).describe('Instrucciones de mejora sobre el conjunto de personajes.'),
|
|
|
|
|
},
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, feedback }) => {
|
|
|
|
|
const s = slug as string;
|
|
|
|
|
const personajes = await apiGet(`/api/projects/${seg(s)}/personajes`);
|
|
|
|
|
return apiPost(`/api/projects/${seg(s)}/personajes`, { action: 'improve', feedback, personajes });
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// CHARACTER ASSETS (imágenes de personaje)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_character_assets', {
|
|
|
|
|
title: 'Ver assets de personaje',
|
|
|
|
|
description: 'Devuelve los assets de imagen de un personaje (T-poses, emociones): prompts, estado y URLs. baseLocked indica si la imagen base está fijada como referencia.',
|
|
|
|
|
inputSchema: { slug: slugField, personajeId: z.string().min(1).describe('id del personaje (de story_get_personajes).') },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug, personajeId }) =>
|
|
|
|
|
apiGet(`/api/projects/${seg(slug as string)}/personajes/${seg(personajeId as string)}/assets`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_asset_prompts', {
|
|
|
|
|
title: 'Generar prompts de assets',
|
|
|
|
|
description: 'Paso 1 del workflow de assets: genera con GPT-4o los prompts de las 9 imágenes (3 T-poses + 6 emociones) del personaje.',
|
|
|
|
|
inputSchema: { slug: slugField, personajeId: z.string().min(1).describe('id del personaje.') },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, personajeId }) =>
|
|
|
|
|
apiPost(`/api/projects/${seg(slug as string)}/personajes/${seg(personajeId as string)}/assets`, { action: 'generate-prompts' })));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_base_image', {
|
|
|
|
|
title: 'Generar imagen base de personaje',
|
|
|
|
|
description: 'Paso 2: genera la imagen base canónica (T-pose frontal) del personaje con Gemini. Acepta un prompt override opcional.',
|
|
|
|
|
inputSchema: {
|
|
|
|
|
slug: slugField,
|
|
|
|
|
personajeId: z.string().min(1).describe('id del personaje.'),
|
|
|
|
|
prompt: z.string().optional().describe('Prompt override opcional para la imagen base.'),
|
|
|
|
|
},
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, personajeId, prompt }) =>
|
|
|
|
|
apiPost(`/api/projects/${seg(slug as string)}/personajes/${seg(personajeId as string)}/assets`,
|
|
|
|
|
prompt ? { action: 'generate-base-image', prompt } : { action: 'generate-base-image' })));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_lock_base_image', {
|
|
|
|
|
title: 'Fijar imagen base',
|
|
|
|
|
description: 'Paso 3: fija la imagen base como referencia canónica (baseLocked=true). Necesario antes de generar variaciones.',
|
|
|
|
|
inputSchema: { slug: slugField, personajeId: z.string().min(1).describe('id del personaje.') },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, personajeId }) =>
|
|
|
|
|
apiPost(`/api/projects/${seg(slug as string)}/personajes/${seg(personajeId as string)}/assets`, { action: 'lock-base' })));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_all_variations', {
|
|
|
|
|
title: 'Generar todas las variaciones de personaje',
|
|
|
|
|
description: 'Paso 4: genera con Gemini (img2img desde la base) todas las T-poses y emociones pendientes del personaje. Requiere baseLocked. Puede tardar minutos.',
|
|
|
|
|
inputSchema: { slug: slugField, personajeId: z.string().min(1).describe('id del personaje.') },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, personajeId }) =>
|
|
|
|
|
apiPost(`/api/projects/${seg(slug as string)}/personajes/${seg(personajeId as string)}/assets`, { action: 'generate-all-variations' })));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// CAPÍTULOS
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_list_capitulos', {
|
|
|
|
|
title: 'Listar capítulos',
|
|
|
|
|
description: 'Lista los capítulos del proyecto (número, título, estado, paso actual).',
|
|
|
|
|
inputSchema: { slug: slugField },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug }) => apiGet(`/api/projects/${seg(slug as string)}/capitulos`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_create_capitulo', {
|
|
|
|
|
title: 'Crear capítulo',
|
|
|
|
|
description: 'Crea un capítulo. Si pasas virtudSugerida, además genera y guarda su guión conductor (GPT-4o) automáticamente.',
|
|
|
|
|
inputSchema: {
|
|
|
|
|
slug: slugField,
|
|
|
|
|
numero: numeroField,
|
|
|
|
|
titulo: z.string().optional().describe('Título del capítulo (opcional; default "Capítulo N").'),
|
|
|
|
|
virtudSugerida: z.string().optional().describe('Si se indica, autogenera el guión conductor con esta virtud.'),
|
|
|
|
|
},
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero, titulo, virtudSugerida }) => {
|
|
|
|
|
const body: Record<string, unknown> = { numero };
|
|
|
|
|
if (titulo !== undefined) body.titulo = titulo;
|
|
|
|
|
if (virtudSugerida !== undefined) body.virtudSugerida = virtudSugerida;
|
|
|
|
|
return apiPost(`/api/projects/${seg(slug as string)}/capitulos`, body);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// GUIÓN CONDUCTOR
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_guion', {
|
|
|
|
|
title: 'Ver guión conductor',
|
|
|
|
|
description: 'Devuelve el guión conductor del capítulo (título, virtud, nudo del mundo, aliado, actos/segmentos, markdown) o null si no existe.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/guion`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_guion', {
|
|
|
|
|
title: 'Generar guión conductor',
|
|
|
|
|
description: 'Genera con GPT-4o el guión conductor del capítulo (a partir de Idea Motriz + personajes) y lo guarda. Requiere Idea Motriz y personajes.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero }) =>
|
|
|
|
|
apiPost(`${capBase(slug as string, numero as number)}/guion`, { action: 'generate' })));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// PRE-ESCALETA (esqueleto de planos, computado sin LLM)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_pre_escaleta', {
|
|
|
|
|
title: 'Ver pre-escaleta',
|
|
|
|
|
description: 'Devuelve el esqueleto de planos (segmentos y número de planos por segmento) del capítulo, o null.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/pre-escaleta`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_pre_escaleta', {
|
|
|
|
|
title: 'Generar pre-escaleta',
|
|
|
|
|
description: 'Computa la pre-escaleta (planos por segmento, sin LLM) desde los actos del guión conductor y la guarda. Requiere un guión con actos estructurados.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero }) => {
|
|
|
|
|
const s = slug as string; const n = numero as number;
|
|
|
|
|
const guion = await apiGet(`${capBase(s, n)}/guion`);
|
|
|
|
|
if (!guion || !(guion as any).actos?.length) {
|
|
|
|
|
throw new ApiError(400, '', 'El guión no tiene actos estructurados. Usa story_generate_guion primero.');
|
|
|
|
|
}
|
|
|
|
|
return apiPost(`${capBase(s, n)}/pre-escaleta`, { action: 'generate', guion });
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// ESCALETA (desglose de planos con personajes)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_escaleta', {
|
|
|
|
|
title: 'Ver escaleta',
|
|
|
|
|
description: 'Devuelve la escaleta del capítulo (planos con acción, sonido, personajes en plano y duración) o null.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/escaleta`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_escaleta', {
|
|
|
|
|
title: 'Generar escaleta',
|
|
|
|
|
description: 'Genera con GPT-4o la escaleta (desglose de planos) desde el guión conductor y la pre-escaleta, y la guarda. Requiere guión y pre-escaleta creados.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero }) => {
|
|
|
|
|
const s = slug as string; const n = numero as number;
|
|
|
|
|
const guion = await apiGet(`${capBase(s, n)}/guion`);
|
|
|
|
|
if (!guion || !(guion as any).actos?.length) {
|
|
|
|
|
throw new ApiError(400, '', 'Falta el guión con actos. Usa story_generate_guion y story_generate_pre_escaleta antes.');
|
|
|
|
|
}
|
|
|
|
|
return apiPost(`${capBase(s, n)}/escaleta`, { action: 'generate', guion });
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// PROMPTS DE IMAGEN (por plano)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_prompts', {
|
|
|
|
|
title: 'Ver prompts de imagen',
|
|
|
|
|
description: 'Devuelve el resumen de prompts de imagen por plano del capítulo (markdown + total) o null.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/prompts`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_prompts', {
|
|
|
|
|
title: 'Generar prompts de imagen',
|
|
|
|
|
description: 'Genera con GPT-4o los prompts de imagen de todos los planos (desde la escaleta) y los guarda. Requiere escaleta creada.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero }) =>
|
|
|
|
|
apiPost(`${capBase(slug as string, numero as number)}/prompts`, { action: 'generate' })));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// FONDOS (escenarios de referencia)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_fondos', {
|
|
|
|
|
title: 'Ver fondos',
|
|
|
|
|
description: 'Lista los fondos de referencia del capítulo (uno por escenario/segmento) con su estado de imagen.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/fondos`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_refresh_fondos', {
|
|
|
|
|
title: 'Recalcular fondos',
|
|
|
|
|
description: 'Re-extrae la lista de fondos desde el guión/escaleta (sin generar imágenes). Útil tras cambiar la escaleta.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero }) =>
|
|
|
|
|
apiPost(`${capBase(slug as string, numero as number)}/fondos`, { action: 'refresh' })));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_fondo', {
|
|
|
|
|
title: 'Generar imagen de un fondo',
|
|
|
|
|
description: 'Genera con Gemini la imagen de UN fondo (escenario vacío, sin personajes) por su id.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField, fondoId: z.string().min(1).describe('id del fondo (p.ej. "fondo-I.1").') },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero, fondoId }) =>
|
|
|
|
|
apiPost(`${capBase(slug as string, numero as number)}/fondos`, { action: 'generate', fondoId })));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_all_fondos', {
|
|
|
|
|
title: 'Generar todos los fondos',
|
|
|
|
|
description: 'Genera con Gemini las imágenes de todos los fondos del capítulo. Puede tardar varios minutos.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero }) =>
|
|
|
|
|
apiPost(`${capBase(slug as string, numero as number)}/fondos`, { action: 'generate-all' })));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// IMÁGENES DE PLANO
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_imagenes', {
|
|
|
|
|
title: 'Ver imágenes de plano',
|
|
|
|
|
description: 'Devuelve los planos del capítulo con su estado de imagen (generado / con prompt) y contadores.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/imagenes`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_plano_image', {
|
|
|
|
|
title: 'Generar imagen de un plano',
|
|
|
|
|
description: 'Genera con Gemini la imagen de UN plano por su id (usando el prompt del plano + referencias de personaje/fondo).',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField, planoId: z.string().min(1).describe('id del plano (p.ej. "plano-001").') },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero, planoId }) =>
|
|
|
|
|
apiPost(`${capBase(slug as string, numero as number)}/imagenes`, { action: 'generate', planoId })));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_all_images', {
|
|
|
|
|
title: 'Generar todas las imágenes de plano',
|
|
|
|
|
description: 'Genera con Gemini las imágenes de todos los planos del capítulo que tengan prompt. Puede tardar bastante (una llamada por plano).',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero }) =>
|
|
|
|
|
apiPost(`${capBase(slug as string, numero as number)}/imagenes`, { action: 'generate-all' })));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// VÍDEOS (por plano)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_videos', {
|
|
|
|
|
title: 'Ver vídeos de plano',
|
|
|
|
|
description: 'Devuelve los planos del capítulo con sus variaciones de vídeo (estado, seleccionada) y contadores.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ slug, numero }) => apiGet(`${capBase(slug as string, numero as number)}/videos`)));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_generate_plano_video', {
|
|
|
|
|
title: 'Generar vídeo de un plano',
|
|
|
|
|
description: 'Genera una variación de vídeo para un plano (image-to-video) a partir de su imagen. Opcionalmente con un prompt de movimiento personalizado. La generación de vídeo puede ser async según el proveedor configurado.',
|
|
|
|
|
inputSchema: {
|
|
|
|
|
slug: slugField,
|
|
|
|
|
numero: numeroField,
|
|
|
|
|
planoId: z.string().min(1).describe('id del plano.'),
|
|
|
|
|
promptVideo: z.string().optional().describe('Prompt de movimiento opcional (si se omite se compone desde el plano).'),
|
|
|
|
|
},
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero, planoId, promptVideo }) => {
|
|
|
|
|
const body: Record<string, unknown> = { action: 'generate', planoId };
|
|
|
|
|
if (promptVideo !== undefined) body.promptVideo = promptVideo;
|
|
|
|
|
return apiPost(`${capBase(slug as string, numero as number)}/videos`, body);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// RENDER FINAL (Remotion)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_render_chapter', {
|
|
|
|
|
title: 'Renderizar capítulo (Remotion)',
|
|
|
|
|
description: 'Lanza el render final del capítulo (Remotion) en background y devuelve un jobId. Consulta el progreso con story_get_render_status.',
|
|
|
|
|
inputSchema: { slug: slugField, numero: numeroField },
|
|
|
|
|
annotations: GEN,
|
|
|
|
|
}, wrap(async ({ slug, numero }) =>
|
|
|
|
|
apiPost('/api/render', { projectSlug: slug, capituloNumero: numero })));
|
|
|
|
|
|
|
|
|
|
server.registerTool('story_get_render_status', {
|
|
|
|
|
title: 'Estado de un render',
|
|
|
|
|
description: 'Consulta el progreso/estado de un job de render por su jobId (devuelto por story_render_chapter).',
|
|
|
|
|
inputSchema: { jobId: z.string().min(1).describe('jobId devuelto por story_render_chapter.') },
|
|
|
|
|
annotations: READ,
|
|
|
|
|
}, wrap(async ({ jobId }) => apiGet(`/api/render?jobId=${seg(jobId as string)}`)));
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// Arranque (stdio)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
async function main(): Promise<void> {
|
|
|
|
|
const transport = new StdioServerTransport();
|
|
|
|
|
await server.connect(transport);
|
|
|
|
|
console.error(`[story-studio-mcp] conectado (stdio). API: ${BASE_URL}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
main().catch((err) => {
|
|
|
|
|
console.error('[story-studio-mcp] error fatal:', err);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
});
|