@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
371 lines • 19 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { formatSuccess } from "@withpica/mcp-utils";
/**
* The page sizes this surface enforces, written down once.
*
* The authority is `lib/services/catalog-selection/limits.ts` in the PICA
* app; this package is published standalone and cannot import it, so this is
* a hand-kept mirror — but every description below is BUILT from it, so the
* prose an agent reads and the numbers a caller gets cannot drift apart
* within this file. They were previously typed out twice in prose here and
* declared twice more in the app.
*/
const SELECTION_LIMITS = {
listDefault: 50,
listMax: 200,
resolveDefault: 100,
resolveMax: 500,
/**
* Spelled in words on purpose. `scripts/ci/check-agent-readability.mjs`
* reads every agent-facing description and counts a bare numeral of this
* shape as an HTTP status leaked into the reader's vocabulary; the ratchet
* does not distinguish a quantity from a status, and the count is a gate.
*/
resolveMaxInWords: "five hundred",
};
/**
* What a clause's `substrate_rows` counts.
*
* The number beside it, `org_rows`, is always works — so the two are not two
* halves of one fraction, and printing them as "854/200 rows carry this
* data" invented a proportion out of two different units. A role clause
* counts credits, a trait clause counts analysed audio, and everything else
* counts works.
*/
const SUBSTRATE_UNIT_LABEL = {
works: "works",
credit_rows: "credits",
audio_analysis_rows: "analysed audio files",
audio_files: "audio files",
};
function describeCoverage(entry) {
const carried = `${entry.substrate_rows} ${SUBSTRATE_UNIT_LABEL[entry.substrate_unit]} carry this data`;
return entry.evaluable
? `${entry.clause}: ${carried}`
: `${entry.clause}: could not be evaluated here (${carried})`;
}
const SPECIALIST_REASON = "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.";
const PREDICATE_SCHEMA_DESCRIPTION = "The selection predicate: { version: 1, all: [...clauses] }. Every clause " +
"in `all` must match (AND — there is no OR yet). Clause kinds: " +
"{kind:'text', fields:['title'|'lyrics'], value} substring match on title " +
"and/or lyrics; {kind:'role', role_class:'wrote'|'arranged'|'published'|" +
"'owned'|'played'|'produced'|'engineered'|'remixed'|'other', person_id} " +
"credit by normalised role class (covers every spelling of that role on " +
"both the publishing and recording side); {kind:'registration', " +
"state:'registered'|'unregistered'} whether the work carries an ISWC; " +
"{kind:'status', values:[...]} work_status membership; {kind:'trait', " +
"trait:'valence'|'detected_energy'|'detected_danceability'|" +
"'acousticness'|'detected_bpm', min?, max?} an audio-analysis range " +
"(needs at least one of min/max); {kind:'composed', year_from?, " +
"year_to?} composition-date range (needs at least one of year_from/" +
"year_to); {kind:'has_audio', value?:true|false} whether the work has " +
"any audio file on it or on one of its recordings (value defaults to " +
"true; false selects works with no audio). Send the object exactly as " +
"returned on a previous selection when editing one.";
export class SelectionsTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
return [
{
definition: {
name: "pica_selections_query",
scope: "read:catalog",
audiences: ["stdio", "http"],
tier: "read",
description: "List saved catalog selections for the organization — named, " +
'persisted predicates over the catalogue (e.g. "songs from ' +
'2019", "demos by fez"). Shows name, renderer, and when it ' +
"was last resolved. For one selection in full, with its live " +
"matches, use pica_selections_inspect.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: SPECIALIST_REASON,
inputSchema: {
type: "object",
properties: {
limit: {
type: "number",
minimum: 1,
maximum: SELECTION_LIMITS.listMax,
description: `Max selections to return (default ${SELECTION_LIMITS.listDefault}, max ${SELECTION_LIMITS.listMax}).`,
},
offset: {
type: "number",
description: "Pagination offset.",
},
},
},
annotations: {
readOnlyHint: true,
},
},
executor: this.querySelections.bind(this),
},
{
definition: {
name: "pica_selections_inspect",
scope: "read:catalog",
audiences: ["stdio", "http"],
tier: "read",
description: "Get a saved selection by id — find the id with " +
"pica_selections_query. With resolve:true, also runs the " +
"predicate against the catalogue right now and returns the " +
"matching works plus a coverage report: for each clause, " +
"whether it could be evaluated and how much of the catalogue " +
"actually carries that data. A zero-match result where a " +
"clause's data coverage is zero means the catalogue has no " +
'data to answer the question — never report that as "no such ' +
'songs" without saying so.',
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: SPECIALIST_REASON,
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Selection ID (UUID).",
},
resolve: {
type: "boolean",
description: "When true, resolve the predicate against the catalogue " +
"and return members and a coverage report alongside the " +
"selection. Defaults to false (selection metadata only).",
},
limit: {
type: "number",
minimum: 1,
maximum: SELECTION_LIMITS.resolveMax,
description: `Max members to return when resolve is true (default ${SELECTION_LIMITS.resolveDefault}, capped at ${SELECTION_LIMITS.resolveMaxInWords} per call).`,
},
offset: {
type: "number",
description: "Pagination offset for members when resolve is true.",
},
},
required: ["id"],
},
annotations: {
readOnlyHint: true,
idempotentHint: true,
},
},
executor: this.inspectSelection.bind(this),
},
{
definition: {
name: "pica_selections_create",
scope: "write:catalog",
audiences: ["stdio", "http"],
tier: "write",
description: "Save a catalog selection — a named predicate over the " +
"catalogue that stays live (it re-resolves every time it's " +
"opened, so it grows and shrinks as the catalogue changes). " +
"The renderer is a stored label for how the set is meant to be " +
"shown later, 'listen' or 'worklist'; neither is built yet, so " +
"a selection does not play anything itself. Resolved members " +
"carry `playable` — the song's master audio file (or a " +
"fallback, marked is_master:false) plus its other audio files — " +
"or null when the song has no audio; add {kind:'has_audio'} to " +
"keep only songs with audio. Use pica_selections_inspect " +
"with resolve:true right after creating to see what it " +
"actually matches and whether the catalogue has the data to " +
"answer it.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: SPECIALIST_REASON,
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: 'Name for the selection, e.g. "songs from 2019".',
},
description: {
type: "string",
description: "Optional longer description.",
},
predicate: {
type: "object",
description: PREDICATE_SCHEMA_DESCRIPTION,
},
renderer: {
type: "string",
enum: ["listen", "worklist"],
description: "How the selection is meant to be presented once a " +
"renderer exists — 'listen' (the default) or " +
"'worklist'. Stored and returned; nothing reads it yet.",
},
},
required: ["name", "predicate"],
},
},
executor: this.createSelection.bind(this),
},
{
definition: {
name: "pica_selections_update",
scope: "write:catalog",
audiences: ["stdio", "http"],
tier: "write",
description: "Update a saved selection's name, description, or predicate — " +
"find the id with pica_selections_query. Renderer and " +
"visibility are not updatable here — recreate the selection to " +
"change those. Omitted fields are left unchanged.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: SPECIALIST_REASON,
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Selection ID (UUID).",
},
name: {
type: "string",
description: "New name.",
},
description: {
type: "string",
description: "New description.",
},
predicate: {
type: "object",
description: "Replacement predicate — same shape as pica_selections_create's predicate.",
},
},
required: ["id"],
},
annotations: {
idempotentHint: true,
},
},
executor: this.updateSelection.bind(this),
},
{
definition: {
name: "pica_selections_delete",
scope: "write:catalog",
audiences: ["stdio"],
tier: "destructive",
description: "Delete a saved selection by id — find the id with " +
"pica_selections_query. The saved predicate and its name are " +
"gone; the works it matched are untouched. Deleting an id that " +
"is not there is reported as not found, not as a deletion.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Destructive operation; invoked by exact name after explicit user confirmation in a specialist flow.",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Selection ID (UUID).",
},
},
required: ["id"],
},
annotations: {
destructiveHint: true,
idempotentHint: false,
},
},
executor: this.deleteSelection.bind(this),
},
];
}
async querySelections(args) {
const selections = await this.pica.selections.list({
limit: args.limit,
offset: args.offset,
});
const hints = [];
if (!selections.length) {
hints.push({
gap: "no_selections",
suggestion: "Save a catalog selection to give it a name and recall it later (pica_selections_create)",
severity: "nice_to_have",
});
}
return formatSuccess(`Found ${selections.length} selection(s)`, selections, hints);
}
async inspectSelection(args) {
const { selection, resolution } = await this.pica.selections.inspect(args.id, {
resolve: args.resolve === true,
limit: args.limit,
offset: args.offset,
});
if (!resolution) {
return formatSuccess("Selection details", { selection });
}
// The whole reason this tool exists: an empty resolution must never
// read as "the catalogue has no such songs" when a clause simply has
// no data behind it. A clause with `substrate_rows === 0`, or one the
// resolver could not evaluate at all, means the question could not be
// answered from this org's data — different from `substrate_rows > 0`
// and zero matches, which really is "no such songs". Both `message`
// (the text block) and `data` (the structured payload) carry the full
// coverage array — required because the claude.ai connector REPLACES
// the text block with structuredContent rather than showing both.
const unanswerable = resolution.coverage.filter((c) => !c.evaluable || c.substrate_rows === 0);
const coverageText = resolution.coverage.map(describeCoverage).join("; ");
// Every clause carries the same org_rows — the org's work count — so it
// is stated once, in its own unit, rather than as the denominator of a
// fraction whose numerator counts something else.
const orgWorks = resolution.coverage[0]?.org_rows;
const catalogueSize = orgWorks === undefined ? "" : ` The catalogue holds ${orgWorks} works.`;
const message = resolution.total === 0 && unanswerable.length > 0
? `No members. ${unanswerable.map((c) => c.clause).join(", ")} ` +
`has no data in this catalogue — the selection could not be ` +
`answered, which is different from having no matches. ` +
`Coverage: ${coverageText}.${catalogueSize}`
: `${resolution.total} member(s). ` +
`Coverage: ${coverageText}.${catalogueSize}`;
return formatSuccess(message, { selection, resolution });
}
async createSelection(args) {
if (!args.name || !args.predicate) {
return formatSuccess("Please provide a name and a predicate to create a selection", null);
}
const selection = await this.pica.selections.create({
name: args.name,
description: args.description,
predicate: args.predicate,
renderer: args.renderer,
});
return formatSuccess(`Selection "${selection.name}" created`, selection, selection?.id
? [
{
gap: "selection_unresolved",
suggestion: "Resolve it now to see what it matches and whether the catalogue has the data to answer it (pica_selections_inspect with resolve:true)",
severity: "nice_to_have",
},
]
: []);
}
async updateSelection(args) {
const { id, ...updates } = args;
// Forward only the keys explicitly passed, mirroring the route's
// narrow-allow-list semantics. A payload carrying none of them is not
// an error: the update route has no empty-patch check, so it touches
// the selection's updated_at and returns it otherwise unchanged.
const payload = {};
if (updates.name !== undefined)
payload.name = updates.name;
if (updates.description !== undefined)
payload.description = updates.description;
if (updates.predicate !== undefined)
payload.predicate = updates.predicate;
const selection = await this.pica.selections.update(id, payload);
return formatSuccess("Selection updated", selection);
}
async deleteSelection(args) {
await this.pica.selections.delete(args.id);
return formatSuccess(`Selection ${args.id} deleted`);
}
}
//# sourceMappingURL=selections.js.map