Initial release
This commit is contained in:
Generated
+2152
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user