@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
330 lines • 15.4 kB
JavaScript
// 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