Initial release

This commit is contained in:
civ
2026-08-16 18:24:52 +07:00
commit 876886a39a
13244 changed files with 2353959 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "mcp-lyricstudio",
"version": "1.0.0",
"description": "MCP server for HOT-Step Lyric Studio — lets Antigravity act as the LLM",
"type": "module",
"main": "src/index.ts",
"scripts": {
"start": "tsx src/index.ts",
"build": "echo No build step - runs from source via tsx; prompts are shared from server/src/services/lireek/prompts.ts",
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"better-sqlite3": "^11.8.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.15.0",
"tsx": "^4.19.0",
"typescript": "^5.8.3"
}
}
+244
View File
@@ -0,0 +1,244 @@
// db.ts — Database access layer for mcp-lyricstudio
//
// Connects directly to hotstep.db via better-sqlite3.
// Query functions mirror those in server/src/db/lireekDb.ts.
import Database from 'better-sqlite3';
import path from 'path';
let db: Database.Database;
/**
* Resolve the database path from environment or default.
* Priority: HOTSTEP_DB env > default relative path.
*/
function resolveDbPath(): string {
if (process.env.HOTSTEP_DB) {
return path.resolve(process.env.HOTSTEP_DB);
}
// Default: relative to this package's location in tools/mcp-lyricstudio/
return path.resolve(import.meta.dirname, '..', '..', 'server', 'data', 'hotstep.db');
}
export function initDb(): void {
const dbPath = resolveDbPath();
console.error(`[mcp-lyricstudio] Opening database: ${dbPath}`);
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
}
export function getDb(): Database.Database {
if (!db) throw new Error('Database not initialized');
return db;
}
// ── Artists ──────────────────────────────────────────────────────────────────
export function listArtists(): any[] {
return getDb().prepare(
`SELECT a.*, COUNT(ls.id) AS lyrics_set_count
FROM artists a LEFT JOIN lyrics_sets ls ON ls.artist_id = a.id
GROUP BY a.id ORDER BY a.name`
).all();
}
export function getArtist(id: number): any {
return getDb().prepare('SELECT * FROM artists WHERE id = ?').get(id);
}
// ── Lyrics Sets ─────────────────────────────────────────────────────────────
export function getLyricsSet(id: number): any {
const row = getDb().prepare(
`SELECT ls.*, a.name as artist_name FROM lyrics_sets ls
JOIN artists a ON a.id = ls.artist_id WHERE ls.id = ?`
).get(id) as any;
if (!row) return null;
row.songs = JSON.parse(row.songs);
row.total_songs = row.songs.length;
return row;
}
export function getLyricsSets(artistId?: number): any[] {
const db = getDb();
// profile_id is NULL when the set has never been profiled — this is what makes
// "which artists still need a profile?" answerable without querying SQLite directly.
const profileSubquery =
`(SELECT p.id FROM profiles p WHERE p.lyrics_set_id = ls.id
ORDER BY p.created_at DESC LIMIT 1) AS profile_id`;
const query = artistId
? db.prepare(
`SELECT ls.*, a.name as artist_name, ${profileSubquery} FROM lyrics_sets ls
JOIN artists a ON a.id = ls.artist_id
WHERE ls.artist_id = ? ORDER BY ls.fetched_at DESC`
)
: db.prepare(
`SELECT ls.*, a.name as artist_name, ${profileSubquery} FROM lyrics_sets ls
JOIN artists a ON a.id = ls.artist_id
ORDER BY a.name, ls.fetched_at DESC`
);
const rows = (artistId ? query.all(artistId) : query.all()) as any[];
return rows.map(r => {
const songs = JSON.parse(r.songs);
const { songs: _, ...rest } = r;
return { ...rest, total_songs: songs.length };
});
}
// ── Profiles ────────────────────────────────────────────────────────────────
export function listProfiles(artistId?: number): any[] {
const db = getDb();
let rows: any[];
if (artistId) {
rows = db.prepare(
`SELECT p.*, ls.album, ls.artist_id, a.name as artist_name
FROM profiles p
JOIN lyrics_sets ls ON ls.id = p.lyrics_set_id
JOIN artists a ON a.id = ls.artist_id
WHERE ls.artist_id = ?
ORDER BY p.created_at DESC`
).all(artistId);
} else {
rows = db.prepare(
`SELECT p.*, ls.album, ls.artist_id, a.name as artist_name
FROM profiles p
JOIN lyrics_sets ls ON ls.id = p.lyrics_set_id
JOIN artists a ON a.id = ls.artist_id
ORDER BY a.name, p.created_at DESC`
).all();
}
return rows.map(r => ({
...r,
profile_data: JSON.parse(r.profile_data),
}));
}
export function getProfile(id: number): any {
const row = getDb().prepare(
`SELECT p.*, ls.album, ls.artist_id, a.name as artist_name
FROM profiles p
JOIN lyrics_sets ls ON ls.id = p.lyrics_set_id
JOIN artists a ON a.id = ls.artist_id
WHERE p.id = ?`
).get(id) as any;
if (!row) return null;
row.profile_data = JSON.parse(row.profile_data);
return row;
}
export function saveProfile(
lyricsSetId: number, provider: string, model: string, profileData: any
): any {
const now = new Date().toISOString();
const result = getDb().prepare(
'INSERT INTO profiles (lyrics_set_id, provider, model, profile_data, created_at) VALUES (?, ?, ?, ?, ?)'
).run(lyricsSetId, provider, model, JSON.stringify(profileData), now);
return {
id: result.lastInsertRowid, lyrics_set_id: lyricsSetId,
provider, model, profile_data: profileData, created_at: now,
};
}
// ── Generations ─────────────────────────────────────────────────────────────
export interface SaveGenerationParams {
profileId: number;
provider: string;
model: string;
lyrics: string;
title?: string;
subject?: string;
bpm?: number;
key?: string;
caption?: string;
duration?: number;
systemPrompt?: string;
userPrompt?: string;
parentGenerationId?: number | null;
}
export function saveGeneration(p: SaveGenerationParams): any {
const now = new Date().toISOString();
const result = getDb().prepare(
`INSERT INTO generations
(profile_id, provider, model, extra_instructions, title, subject, bpm, key, caption, duration, lyrics, system_prompt, user_prompt, parent_generation_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
p.profileId, p.provider, p.model, null,
p.title ?? '', p.subject ?? '', p.bpm ?? 0, p.key ?? '', p.caption ?? '', p.duration ?? 0,
p.lyrics, p.systemPrompt ?? '', p.userPrompt ?? '', p.parentGenerationId ?? null, now,
);
return {
id: result.lastInsertRowid, profile_id: p.profileId, provider: p.provider,
model: p.model, title: p.title ?? '', subject: p.subject ?? '',
bpm: p.bpm ?? 0, key: p.key ?? '', caption: p.caption ?? '', duration: p.duration ?? 0,
lyrics: p.lyrics, parent_generation_id: p.parentGenerationId ?? null, created_at: now,
};
}
export function getGeneration(id: number): any {
return (getDb().prepare('SELECT * FROM generations WHERE id = ?').get(id) as any) ?? null;
}
export function listGenerations(profileId?: number, artistId?: number, limit = 20): any[] {
const db = getDb();
if (profileId) {
return db.prepare(
`SELECT g.*, a.name AS artist_name, ls.album
FROM generations g
JOIN profiles p ON p.id = g.profile_id
JOIN lyrics_sets ls ON ls.id = p.lyrics_set_id
JOIN artists a ON a.id = ls.artist_id
WHERE g.profile_id = ?
ORDER BY g.created_at DESC LIMIT ?`
).all(profileId, limit);
}
if (artistId) {
return db.prepare(
`SELECT g.*, a.name AS artist_name, ls.album
FROM generations g
JOIN profiles p ON p.id = g.profile_id
JOIN lyrics_sets ls ON ls.id = p.lyrics_set_id
JOIN artists a ON a.id = ls.artist_id
WHERE ls.artist_id = ?
ORDER BY g.created_at DESC LIMIT ?`
).all(artistId, limit);
}
return db.prepare(
`SELECT g.*, a.name AS artist_name, ls.album
FROM generations g
JOIN profiles p ON p.id = g.profile_id
JOIN lyrics_sets ls ON ls.id = p.lyrics_set_id
JOIN artists a ON a.id = ls.artist_id
ORDER BY g.created_at DESC LIMIT ?`
).all(limit);
}
/**
* Get generation history for diversity tracking (used subjects, BPMs, keys, titles).
*/
export function getGenerationHistory(artistId: number): {
usedSubjects: string[];
usedBpms: number[];
usedKeys: string[];
usedTitles: string[];
usedDurations: number[];
} {
const rows = getDb().prepare(
`SELECT g.subject, g.bpm, g.key, g.title, g.duration
FROM generations g
JOIN profiles p ON p.id = g.profile_id
JOIN lyrics_sets ls ON ls.id = p.lyrics_set_id
WHERE ls.artist_id = ?`
).all(artistId) as any[];
return {
usedSubjects: rows.map(r => r.subject).filter(Boolean),
usedBpms: rows.map(r => r.bpm).filter((b: number) => b > 0),
usedKeys: rows.map(r => r.key).filter(Boolean),
usedTitles: rows.map(r => r.title).filter(Boolean),
usedDurations: rows.map(r => r.duration).filter((d: number) => d > 0),
};
}
+628
View File
@@ -0,0 +1,628 @@
// index.ts — MCP server for HOT-Step Lyric Studio
//
// Lets the Antigravity agent act as the LLM for lyric generation,
// refinement, and profile building. Connects to hotstep.db directly.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import * as db from './db.js';
import * as prompts from './prompts.js';
// Model-name title suffix ("Song Name - Fable 5") — canonical implementation
// shared with the in-app pipeline; appended HERE (not in the LLM prompt) so it
// is deterministic.
import { withModelSuffix } from '../../../server/src/services/lireek/modelName.js';
const server = new McpServer({
name: 'lyricstudio',
version: '1.0.0',
});
const MODEL_PARAM_DESC =
"Name of the model YOU are running as (e.g. 'Fable 5', 'claude-opus-4-8', 'Gemini 3 Pro'). " +
'Always pass this — it is appended to the song title ("Song Name - Fable 5") and stored with the generation.';
// ── list_artists ────────────────────────────────────────────────────────────
server.tool(
'list_artists',
'List all artists in the Lyric Studio database with their IDs and lyrics set counts',
{},
async () => {
const artists = db.listArtists();
if (!artists.length) {
return { content: [{ type: 'text', text: 'No artists found in the database.' }] };
}
const lines = ['# Artists\n'];
for (const a of artists) {
lines.push(`- **${a.name}** (ID: ${a.id}) — ${a.lyrics_set_count} lyrics set(s)`);
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
);
// ── list_lyrics_sets ────────────────────────────────────────────────────────
server.tool(
'list_lyrics_sets',
'List lyrics sets with their IDs, song counts, and whether a profile has been built from them. ' +
'Use this to find the lyrics_set_id needed by prepare_profile_build/save_profile, and to find ' +
'which artists still need a profile. Set unprofiled_only to list just the sets with no profile yet.',
{
artist_id: z.number().optional().describe('Filter by artist ID'),
unprofiled_only: z
.boolean()
.optional()
.describe('Only return lyrics sets that do not have a profile yet'),
},
async ({ artist_id, unprofiled_only }) => {
let sets = db.getLyricsSets(artist_id);
if (unprofiled_only) sets = sets.filter((s: any) => s.profile_id == null);
if (!sets.length) {
return {
content: [
{
type: 'text',
text: unprofiled_only
? 'No unprofiled lyrics sets found — every lyrics set already has a profile.'
: 'No lyrics sets found.',
},
],
};
}
const lines = [unprofiled_only ? '# Lyrics Sets (unprofiled)\n' : '# Lyrics Sets\n'];
for (const s of sets) {
const album = s.album ? ` — Album: "${s.album}"` : ' — (all songs)';
const profile =
s.profile_id == null ? '**no profile yet**' : `profile ${s.profile_id}`;
lines.push(
`- **Lyrics Set ${s.id}**: ${s.artist_name}${album}${s.total_songs} song(s), ${profile}`
);
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
);
// ── list_profiles ───────────────────────────────────────────────────────────
server.tool(
'list_profiles',
'List artist profiles with album info. Use artist_id to filter by artist.',
{ artist_id: z.number().optional().describe('Filter by artist ID') },
async ({ artist_id }) => {
const profiles = db.listProfiles(artist_id);
if (!profiles.length) {
return { content: [{ type: 'text', text: 'No profiles found.' }] };
}
const lines = ['# Profiles\n'];
for (const p of profiles) {
const album = p.album ? ` — Album: "${p.album}"` : ' — (all songs)';
lines.push(`- **Profile ${p.id}** (lyrics set ${p.lyrics_set_id}): ${p.artist_name}${album} (built with ${p.provider}/${p.model}, ${p.created_at})`);
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
);
// ── get_profile ─────────────────────────────────────────────────────────────
server.tool(
'get_profile',
'Get the full profile data for a specific profile ID. Returns the artist style analysis.',
{ profile_id: z.number().describe('Profile ID to retrieve') },
async ({ profile_id }) => {
const profile = db.getProfile(profile_id);
if (!profile) {
return { content: [{ type: 'text', text: `Profile ${profile_id} not found.` }] };
}
const pd = profile.profile_data;
const lines = [
`# Profile: ${profile.artist_name}`,
profile.album ? `**Album:** ${profile.album}` : '**Album:** (all songs)',
`**Built with:** ${profile.provider} / ${profile.model}`,
`**Created:** ${profile.created_at}`,
'',
'## Style Summary',
pd.raw_summary || '(no summary)',
'',
'## Key Stats',
`- Themes: ${(pd.themes || []).join(', ')}`,
`- Avg verse: ${pd.avg_verse_lines} lines, Avg chorus: ${pd.avg_chorus_lines} lines`,
`- Perspective: ${pd.perspective || 'unknown'}`,
`- Blueprints: ${(pd.structure_blueprints || []).join(', ')}`,
];
if (pd.tone_and_mood) lines.push(`- Tone: ${pd.tone_and_mood}`);
if (pd.vocabulary_notes) lines.push(`- Vocabulary: ${pd.vocabulary_notes}`);
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
);
// ── prepare_generation ──────────────────────────────────────────────────────
server.tool(
'prepare_generation',
'Prepare prompts for generating new lyrics. Returns the metadata planning prompt that the agent should respond to with a JSON object containing subject, bpm, key, caption, and duration.',
{
profile_id: z.number().describe('Profile ID to generate from'),
extra_instructions: z.string().optional().describe('Extra instructions for generation'),
user_subject: z.string().optional().describe('User-specified subject for the song'),
},
async ({ profile_id, extra_instructions, user_subject }) => {
const profile = db.getProfile(profile_id);
if (!profile) {
return { content: [{ type: 'text', text: `Profile ${profile_id} not found.` }] };
}
const pd = profile.profile_data;
const history = db.getGenerationHistory(profile.artist_id);
// Profiles built before audio enrichment existed: derive it live from the
// lyrics set (Training Studio exports carry bpm/key/genre/caption per song).
if (!pd.audio_enrichment) {
const set = db.getLyricsSet(profile.lyrics_set_id);
if (set) pd.audio_enrichment = prompts.computeAlbumEnrichment(set.songs);
}
const metadataUserPrompt = prompts.buildMetadataPrompt(
pd, history.usedSubjects, history.usedBpms, history.usedKeys, history.usedDurations, user_subject
);
const text = [
`# Generation Prep: ${profile.artist_name}`,
profile.album ? `**Album:** ${profile.album}` : '',
`**Profile ID:** ${profile_id}`,
`**Previous generations:** ${history.usedTitles.length} songs`,
'',
'---',
'',
'## Step 1: Plan Song Metadata',
'',
'Respond to the following prompt with a JSON object containing: subject, bpm, key, caption, duration, structure.',
'',
'### System Prompt',
'```',
prompts.SONG_METADATA_SYSTEM_PROMPT,
'```',
'',
'### User Prompt',
'```',
metadataUserPrompt,
'```',
'',
'---',
'',
'## Step 2',
'After generating the metadata JSON, call `build_lyrics_prompt` with the profile_id and your generated metadata values (subject, bpm, duration, structure, and optionally extra_instructions).',
].filter(Boolean).join('\n');
return { content: [{ type: 'text', text }] };
}
);
// ── build_lyrics_prompt ─────────────────────────────────────────────────────
server.tool(
'build_lyrics_prompt',
'Build the full lyrics generation prompt using profile data and metadata. Call this after generating metadata. Returns system + user prompts for lyric writing.',
{
profile_id: z.number().describe('Profile ID'),
subject: z.string().describe('Song subject from metadata generation'),
bpm: z.number().describe('BPM from metadata generation'),
duration: z.number().describe('Duration in seconds from metadata generation'),
structure: z.string().optional().describe('Planned song structure from metadata generation, e.g. "I-V-C-V-C-B-C-O"'),
extra_instructions: z.string().optional().describe('Extra instructions'),
},
async ({ profile_id, subject, bpm, duration, structure, extra_instructions }) => {
const profile = db.getProfile(profile_id);
if (!profile) {
return { content: [{ type: 'text', text: `Profile ${profile_id} not found.` }] };
}
const pd = profile.profile_data;
// Same live-derivation fallback as prepare_generation (old profiles).
if (!pd.audio_enrichment) {
const set = db.getLyricsSet(profile.lyrics_set_id);
if (set) pd.audio_enrichment = prompts.computeAlbumEnrichment(set.songs);
}
// Add subject to extra instructions
let fullInstructions = `The song must be about: ${subject}`;
if (extra_instructions) fullInstructions += `\n\n${extra_instructions}`;
const userPrompt = prompts.buildGenerationPrompt(pd, fullInstructions, duration, bpm, structure);
const text = [
`# Lyrics Generation: ${profile.artist_name}`,
`**Subject:** ${subject}`,
`**BPM:** ${bpm} | **Duration:** ${duration}s${structure ? ` | **Structure:** ${structure}` : ''}`,
'',
'---',
'',
'## System Prompt',
'```',
prompts.GENERATION_SYSTEM_PROMPT,
'```',
'',
'## User Prompt',
'```',
userPrompt,
'```',
'',
'---',
'',
'Write the lyrics following the system prompt rules. Then call `save_generation` to save the result — ' +
"include the `model` param with your own model name (e.g. 'Fable 5') so it is appended to the title.",
].join('\n');
return { content: [{ type: 'text', text }] };
}
);
// ── save_generation ─────────────────────────────────────────────────────────
server.tool(
'save_generation',
'Save a completed lyric generation to the database. The result appears in the Lyric Studio UI immediately.',
{
profile_id: z.number().describe('Profile ID'),
lyrics: z.string().describe('Generated lyrics'),
title: z.string().describe('Song title (WITHOUT model name — the server appends it)'),
model: z.string().optional().describe(MODEL_PARAM_DESC),
subject: z.string().optional().describe('Song subject'),
bpm: z.number().optional().describe('BPM'),
key: z.string().optional().describe('Musical key (e.g. "C Major")'),
caption: z.string().optional().describe('Audio style caption'),
duration: z.number().optional().describe('Duration in seconds'),
},
async ({ profile_id, lyrics, title, model, subject, bpm, key, caption, duration }) => {
const profile = db.getProfile(profile_id);
if (!profile) {
return { content: [{ type: 'text', text: `Profile ${profile_id} not found.` }] };
}
const saved = db.saveGeneration({
profileId: profile_id,
provider: 'mcp',
model: model || 'unknown',
lyrics,
title: withModelSuffix(title, model),
subject,
bpm,
key,
caption,
duration,
});
return {
content: [{
type: 'text',
text: [
`✅ Generation saved!`,
'',
`**ID:** ${saved.id}`,
`**Title:** ${saved.title}`,
`**Artist:** ${profile.artist_name}${profile.album ? `${profile.album}` : ''}`,
`**Subject:** ${saved.subject}`,
`**BPM:** ${saved.bpm} | **Key:** ${saved.key} | **Duration:** ${saved.duration}s`,
'',
'The generation is now visible in the Lyric Studio UI.',
].join('\n'),
}],
};
}
);
// ── prepare_refinement ──────────────────────────────────────────────────────
server.tool(
'prepare_refinement',
'Prepare prompts for refining an existing generation. Returns the refinement system + user prompts.',
{ generation_id: z.number().describe('Generation ID to refine') },
async ({ generation_id }) => {
const gen = db.getGeneration(generation_id);
if (!gen) {
return { content: [{ type: 'text', text: `Generation ${generation_id} not found.` }] };
}
const profile = db.getProfile(gen.profile_id);
const lyricsSet = profile ? db.getLyricsSet(profile.lyrics_set_id) : null;
const artistName = lyricsSet?.artist_name || profile?.artist_name || 'Unknown';
const pd = profile?.profile_data;
const userPrompt = prompts.buildRefinementPrompt(gen.lyrics, artistName, gen.title, pd);
const text = [
`# Refinement: "${gen.title}" by ${artistName}`,
`**Generation ID:** ${generation_id}`,
`**BPM:** ${gen.bpm} | **Key:** ${gen.key} | **Duration:** ${gen.duration}s`,
'',
'### Original Lyrics',
'```',
gen.lyrics,
'```',
'',
'---',
'',
'## System Prompt',
'```',
prompts.REFINEMENT_SYSTEM_PROMPT,
'```',
'',
'## User Prompt',
'```',
userPrompt,
'```',
'',
'---',
'',
'Write the refined lyrics (starting with "Title: ..."). Then call `save_refinement` to save — ' +
"include the `model` param with your own model name (e.g. 'Fable 5') so it is appended to the title.",
].join('\n');
return { content: [{ type: 'text', text }] };
}
);
// ── save_refinement ─────────────────────────────────────────────────────────
server.tool(
'save_refinement',
'Save refined lyrics as a new generation linked to the parent.',
{
generation_id: z.number().describe('Parent generation ID'),
lyrics: z.string().describe('Refined lyrics'),
title: z.string().describe('Refined title (WITHOUT model name — the server appends it)'),
model: z.string().optional().describe(MODEL_PARAM_DESC),
},
async ({ generation_id, lyrics, title, model }) => {
const parent = db.getGeneration(generation_id);
if (!parent) {
return { content: [{ type: 'text', text: `Generation ${generation_id} not found.` }] };
}
const saved = db.saveGeneration({
profileId: parent.profile_id,
provider: 'mcp',
model: model || 'unknown',
lyrics,
title: withModelSuffix(title, model),
subject: parent.subject,
bpm: parent.bpm,
key: parent.key,
caption: parent.caption,
duration: parent.duration,
parentGenerationId: generation_id,
});
return {
content: [{
type: 'text',
text: [
`✅ Refinement saved!`,
'',
`**ID:** ${saved.id} (parent: ${generation_id})`,
`**Title:** ${saved.title}`,
'',
'The refined generation is now visible in the Lyric Studio UI.',
].join('\n'),
}],
};
}
);
// ── list_generations ────────────────────────────────────────────────────────
server.tool(
'list_generations',
'List recent generations. Filter by profile_id or artist_id.',
{
profile_id: z.number().optional().describe('Filter by profile ID'),
artist_id: z.number().optional().describe('Filter by artist ID'),
limit: z.number().optional().default(20).describe('Max results (default 20)'),
},
async ({ profile_id, artist_id, limit }) => {
const gens = db.listGenerations(profile_id, artist_id, limit);
if (!gens.length) {
return { content: [{ type: 'text', text: 'No generations found.' }] };
}
const lines = ['# Generations\n'];
for (const g of gens) {
const parent = g.parent_generation_id ? ` (refined from #${g.parent_generation_id})` : '';
lines.push(
`- **#${g.id}** "${g.title || '(untitled)'}" — ${g.artist_name}${g.album ? ` / ${g.album}` : ''}` +
` | ${g.provider}/${g.model} | BPM ${g.bpm} | ${g.key} | ${g.duration}s${parent}`
);
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
);
// ── prepare_title ───────────────────────────────────────────────────────────
server.tool(
'prepare_title',
'Prepare a title derivation prompt for lyrics.',
{
lyrics: z.string().describe('The lyrics to title'),
artist_name: z.string().describe('Artist name'),
album: z.string().optional().describe('Album name'),
used_titles: z.array(z.string()).optional().describe('Titles already used (for diversity)'),
},
async ({ lyrics, artist_name, album, used_titles }) => {
const userPrompt = prompts.buildTitlePrompt(lyrics, artist_name, album, used_titles);
const text = [
'## Title Derivation',
'',
'### System Prompt',
'```',
prompts.TITLE_DERIVATION_PROMPT,
'```',
'',
'### User Prompt',
'```',
userPrompt,
'```',
'',
'Respond with ONLY the title. Do NOT include your model name in it — ' +
'when saving, pass your model name via the `model` param and the server appends it automatically.',
].join('\n');
return { content: [{ type: 'text', text }] };
}
);
// ── prepare_profile_build ───────────────────────────────────────────────────
server.tool(
'prepare_profile_build',
'Prepare prompts for building an artist profile from a lyrics set. Returns 4 sequential prompts that need to be answered in order.',
{
lyrics_set_id: z.number().describe('Lyrics set ID to build profile from'),
},
async ({ lyrics_set_id }) => {
const lyricsSet = db.getLyricsSet(lyrics_set_id);
if (!lyricsSet) {
return { content: [{ type: 'text', text: `Lyrics set ${lyrics_set_id} not found.` }] };
}
const songs = lyricsSet.songs;
const artistName = lyricsSet.artist_name;
const album = lyricsSet.album;
// Note: The rule-based analysis (rhyme, meter, structure, etc.) would normally
// be computed by the profilerService. Since we don't have that code here,
// we provide a simplified profile build that focuses on the LLM analysis parts.
// The agent should note that rule-based stats will need to be calculated separately
// or filled in from the lyrics set data.
const songList = songs.map((s: any) => `--- ${s.title} ---\n${s.lyrics}`).join('\n\n');
// Measured audio facts from a Training Studio export — ground truth for
// genre/tempo. Plain Genius-fetched sets yield null and the block is absent.
const enrichment = prompts.computeAlbumEnrichment(songs);
const header = [
`Artist: ${artistName}`,
album ? `Album: ${album}` : '',
`Songs analysed: ${songs.length}`,
...(enrichment ? [
'',
...prompts.formatAlbumEnrichment(enrichment),
'Treat the detected genre and tempo as ground truth — fold them into tone_and_mood and additional_notes rather than inferring a genre from the lyrics alone.',
] : []),
'',
'=== COMPLETE LYRICS ===',
'',
songList,
].filter(Boolean).join('\n');
const text = [
`# Profile Build: ${artistName}${album ? ` — "${album}"` : ''}`,
`**Lyrics Set ID:** ${lyrics_set_id}`,
`**Songs:** ${songs.length}`,
'',
'---',
'',
'## Process',
'Building a profile requires 4 sequential LLM calls. Answer each prompt in order with a JSON response.',
'',
'### Call 1/4: Themes & Vocabulary',
'**System Prompt:**',
'```',
prompts.PROFILE_PROMPT_1,
'```',
'**User Prompt:**',
'```',
header,
'```',
'',
'### Call 2/4: Tone & Structure',
'**System Prompt:**',
'```',
prompts.PROFILE_PROMPT_2,
'```',
'**User Prompt:** (same as above)',
'',
'### Call 3/4: Imagery & Signature',
'**System Prompt:**',
'```',
prompts.PROFILE_PROMPT_3,
'```',
'**User Prompt:** (same as above)',
'',
'### Call 4/4: Song Subjects',
'**System Prompt:**',
'```',
prompts.SUBJECT_ANALYSIS_PROMPT,
'```',
'**User Prompt:**',
'```',
prompts.buildSubjectAnalysisPrompt(songs),
'```',
'',
'---',
'',
'After generating all 4 JSON responses, call `save_profile` with the merged data.',
'',
'> **Note:** Rule-based stats (rhyme analysis, meter, syllable counts, structure blueprints, etc.) are computed by the app\'s profiler service, not by the LLM. When saving the profile, include the LLM-generated fields. The app will have partial data but the most important parts (themes, vocabulary, tone, imagery, subjects) will be present.',
].join('\n');
return { content: [{ type: 'text', text }] };
}
);
// ── save_profile ────────────────────────────────────────────────────────────
server.tool(
'save_profile',
'Save a built profile to the database.',
{
lyrics_set_id: z.number().describe('Lyrics set ID the profile was built from'),
profile_data: z.string().describe('JSON string of the merged profile data object'),
},
async ({ lyrics_set_id, profile_data }) => {
const lyricsSet = db.getLyricsSet(lyrics_set_id);
if (!lyricsSet) {
return { content: [{ type: 'text', text: `Lyrics set ${lyrics_set_id} not found.` }] };
}
let parsed: any;
try {
parsed = JSON.parse(profile_data);
} catch (e) {
return { content: [{ type: 'text', text: 'Failed to parse profile_data JSON.' }] };
}
// Add artist name to the profile data
parsed.artist = lyricsSet.artist_name;
if (lyricsSet.album) parsed.album = lyricsSet.album;
// Deterministic, never agent-derived — measured facts from the source audio.
parsed.audio_enrichment = prompts.computeAlbumEnrichment(lyricsSet.songs);
const saved = db.saveProfile(lyrics_set_id, 'antigravity', 'claude-opus-4', parsed);
return {
content: [{
type: 'text',
text: [
`✅ Profile saved!`,
'',
`**ID:** ${saved.id}`,
`**Artist:** ${lyricsSet.artist_name}${lyricsSet.album ? `${lyricsSet.album}` : ''}`,
`**Lyrics Set:** ${lyrics_set_id}`,
'',
'The profile is now available for generation in the Lyric Studio UI.',
].join('\n'),
}],
};
}
);
// ── Start server ────────────────────────────────────────────────────────────
async function main() {
db.initDb();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[mcp-lyricstudio] Server started');
}
main().catch((err) => {
console.error('[mcp-lyricstudio] Fatal error:', err);
process.exit(1);
});
+10
View File
@@ -0,0 +1,10 @@
// prompts.ts — re-export of the CANONICAL prompt module.
//
// The single source of truth for all Lyric Studio prompts (system prompts,
// prompt builders, blueprint helpers, slop lists) is:
// server/src/services/lireek/prompts.ts
// Do NOT define or fork prompts here — edit them there. This works because the
// MCP server always runs from TypeScript source via tsx (see ../.mcp.json),
// which resolves cross-package .ts imports; there is no separate build step.
export * from '../../../server/src/services/lireek/prompts.js';
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src/**/*.ts"]
}