UNPKG

@withpica/mcp-server

Version:

MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog

330 lines • 15.4 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { formatSuccess } from "@withpica/mcp-utils"; /** Mirrors lib/services/directory/themes.ts THEME_VOCABULARY (parity test). */ export const THEME_VOCABULARY = [ "love", "heartbreak", "longing", "desire", "friendship", "family", "home", "nostalgia", "loss", "loneliness", "hope", "faith", "worship", "gratitude", "joy", "celebration", "freedom", "empowerment", "resilience", "struggle", "rebellion", "identity", "ambition", "money", "peace", "nature", "night", "city", "travel", "summer", ]; /** Mirrors lib/services/mood-derivation.ts MOOD_VOCABULARY (parity test). */ export const MOOD_VOCABULARY = [ "aggressive", "ambient", "calm", "contemplative", "dark", "dramatic", "dreamy", "energetic", "epic", "ethereal", "hopeful", "intense", "melancholic", "nostalgic", "passionate", "playful", "romantic", "sad", "tender", "tense", "triumphant", "uplifting", "warm", ]; function stateText(s) { const list = (xs) => xs.length > 0 ? xs.map((x) => x.theme).join(", ") : "none"; const lines = [ `${s.title}: topics ${s.themes_enabled ? "on" : "switched off"}, lyrics shown publicly as: ${s.lyrics_visibility}.`, `Approved topics: ${list(s.approved)}.`, `Awaiting the owner's review: ${list(s.proposed)}.`, ]; if (s.proposed_moods.length > 0) { lines.push(`Moods awaiting review: ${s.proposed_moods .map((m) => `${m.moods.join(", ")} (recording ${m.recording_id})`) .join("; ")}.`); } if (!s.has_lyrics) lines.push("This song has no lyrics in PICA."); return lines.join("\n"); } export class WorkThemesTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_topics_untagged", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Songs in this catalogue that are listed in the public directory and have lyrics in PICA, " + 'but no approved topic yet, so people searching the directory by topic ("a song about love") ' + "cannot find them. Use when the user asks: 'which of my songs need topics?', " + "'tag my songs for the directory'. Returns each song's id and title and how many proposed " + "topics already await review. Never returns lyrics. " + "→ then: pica_topics_inspect (one song) → pica_topics_propose", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, executor: this.untagged.bind(this), }, { definition: { name: "pica_topics_inspect", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "One song's directory topics: those approved (public when the song is listed), those " + "awaiting the owner's review, moods proposed for its recordings, whether topics are " + "switched on, and how much of the lyrics the public directory may show. Use when the user " + "asks: 'what topics does this song have?', 'what's waiting for my approval?'. Only for " + "songs this catalogue owns. Does not return the lyrics: read them with pica_works_inspect. " + "→ then: pica_topics_review (approve or change)", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { work_id: { type: "string", description: "The song's id (from pica_works_query).", }, }, required: ["work_id"], }, annotations: { readOnlyHint: true }, }, executor: this.inspect.bind(this), }, { definition: { name: "pica_topics_propose", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "draft", description: "Propose the topics a song is about (up to 5, from a fixed list) and moods for its " + "recordings, for the owner to review. Nothing becomes public: proposals wait until " + "pica_topics_review approves them. Use when the user asks: 'suggest topics for my " + "songs', 'tag this song'. Base topics on what the owner's lyrics are actually about (read " + "them with pica_works_inspect), and moods on how the recording sounds (tempo, key, mode, " + "energy); never invent a topic outside the list. A new proposal replaces the song's earlier " + "pending one; approved topics are kept. Only for songs this catalogue owns. " + "→ first: pica_topics_untagged (which songs) → then: show the proposal to the user, " + "and pica_topics_review once they decide", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { work_id: { type: "string", description: "The song's id (from pica_works_query).", }, themes: { type: "array", items: { type: "string", enum: [...THEME_VOCABULARY] }, maxItems: 5, description: "Topics the lyrics are about, most central first. Leave out or empty if none fit.", }, moods: { type: "array", description: "Moods per recording of this song (recording ids from pica_works_inspect).", items: { type: "object", properties: { recording_id: { type: "string", description: "A recording of this song.", }, moods: { type: "array", items: { type: "string", enum: [...MOOD_VOCABULARY] }, description: "How the recording sounds.", }, }, required: ["recording_id", "moods"], }, }, }, required: ["work_id"], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, executor: this.propose.bind(this), }, { definition: { name: "pica_topics_review", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "The owner's decision on a song's topics. Approve proposed topics (one by one or all), " + "reject topics, approve or discard proposed moods, set the approved topics directly, switch " + "topics off for the song, or choose how much of the lyrics the public directory may show " + "(themes only, a snippet, or the full lyrics). Approved topics become public on listed " + "songs; approved moods are saved on the recording. Use when the user asks: 'approve those " + "topics', 'drop heartbreak and keep the rest'. ONLY call after the owner has said yes to " + "exactly this: never approve on their behalf. Only for songs this catalogue owns. " + "→ first: pica_topics_inspect (see what awaits review)", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { work_id: { type: "string", description: "The song's id.", }, approve: { type: "array", items: { type: "string", enum: [...THEME_VOCABULARY] }, description: "Proposed topics to approve.", }, approve_all: { type: "boolean", description: "true approves every topic awaiting review.", }, reject: { type: "array", items: { type: "string", enum: [...THEME_VOCABULARY] }, description: "Topics to remove, proposed or approved.", }, set_themes: { type: "array", items: { type: "string", enum: [...THEME_VOCABULARY] }, maxItems: 5, description: "Make exactly these the song's approved topics. Approved topics left out are removed; other suggestions awaiting review are kept.", }, approve_moods_for: { type: "array", items: { type: "string" }, description: "Recording ids whose proposed moods to save.", }, approve_all_moods: { type: "boolean", description: "true saves every proposed mood on this song.", }, reject_moods_for: { type: "array", items: { type: "string" }, description: "Recording ids whose proposed moods to discard.", }, themes_enabled: { type: "boolean", description: "false hides this song's topics from the directory and from topic search.", }, lyrics_visibility: { type: "string", enum: ["themes", "snippet", "full"], description: "How much of the lyrics the public directory may show. Default themes (topics only).", }, }, required: ["work_id"], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, executor: this.review.bind(this), }, ]; } async untagged() { const result = await this.pica.directory.untaggedThemes(); const songs = result?.songs ?? []; const text = songs.length === 0 ? "Every listed song with lyrics has a topic (or none have lyrics in PICA)." : `${songs.length} listed song${songs.length === 1 ? "" : "s"} with lyrics but no topic yet:\n` + songs .map((s) => `- ${s.title} (${s.work_id})${s.pending_proposals > 0 ? `, ${s.pending_proposals} proposed awaiting review` : ""}`) .join("\n"); return formatSuccess(text, { count: songs.length, songs }); } async inspect(args) { const state = await this.pica.works.getThemes(String(args.work_id)); return formatSuccess(stateText(state), state); } async propose(args) { const state = await this.pica.works.proposeThemes(String(args.work_id), { themes: args.themes, moods: args.moods, }); return formatSuccess(`Proposed, not public. Show the owner and ask before approving.\n${stateText(state)}`, state); } async review(args) { const workId = String(args.work_id); let state = null; if (args.approve !== undefined || args.approve_all === true || args.reject !== undefined || args.approve_moods_for !== undefined || args.approve_all_moods === true || args.reject_moods_for !== undefined) { state = await this.pica.works.reviewThemes(workId, { approve: args.approve_all === true ? "all" : args.approve, reject: args.reject, approve_moods_for: args.approve_all_moods === true ? "all" : args.approve_moods_for, reject_moods_for: args.reject_moods_for, }); } if (args.set_themes !== undefined || args.themes_enabled !== undefined || args.lyrics_visibility !== undefined) { state = await this.pica.works.setThemes(workId, { themes: args.set_themes, themes_enabled: args.themes_enabled, lyrics_visibility: args.lyrics_visibility, }); } if (!state) { return formatSuccess("Nothing to change: pass approve, reject, set_themes, the mood fields, themes_enabled or lyrics_visibility.", {}); } return formatSuccess(`Saved.\n${stateText(state)}`, state); } } //# sourceMappingURL=work-themes.js.map