UNPKG

@withpica/mcp-server

Version:

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

367 lines • 18.8 kB
// 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", 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", 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