@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
1,206 lines • 65.2 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { formatAsText, formatList, formatSuccess, formatDeletion, pageEnvelope, } from "@withpica/mcp-utils";
import { withBillingGate } from "@withpica/mcp-utils";
// Server default when the caller omits limit — mirrors the route's
// parsePaginationParams({ limit: 50 }) default on both audio-files list
// branches (workId/no-workId via getAudioFiles, and unassigned via
// findUnassigned), so pageEnvelope's `limit` reflects what the server
// actually applied even when the agent didn't pass one.
const DEFAULT_AUDIO_QUERY_LIMIT = 50;
/**
* Caps copied from the server so the schema advertises what the route
* enforces: `AUDIO_QUERY_MAX_WORK_IDS` in lib/services/audio-files/urls.ts and
* `AUDIO_BULK_UPDATE_MAX_ITEMS` in lib/services/audio-files/bulk-update.ts.
* Hand-copied (this package cannot import the app); no test ties them —
* change both together.
*/
const AUDIO_QUERY_MAX_WORK_IDS = 100;
const AUDIO_BULK_UPDATE_MAX_ITEMS = 200;
/** Fields a LIST row carries. Everything else is pica_audio_inspect's. */
const AUDIO_LIST_FIELDS = [
"id",
"title",
"filename",
"file_type",
"stem_label",
"version_label",
"classification",
"file_size",
"audio_format",
"duration_seconds",
"detected_bpm",
"detected_key",
"detected_energy",
"detected_mood",
"analysis_status",
"published",
"work_id",
"recording_id",
"storage_bucket",
"uploaded_at",
"created_at",
// Attached by the route on the unassigned path — the whole point of that
// call, so it must survive the projection.
"probable_match",
];
/**
* Project one audio row to its list shape. Dropped on purpose:
* `waveform_data` (the weight — on pica_audio_inspect sections:["waveform"]),
* sample-rate / bit-depth / danceability (inspect sections:["analysis"]),
* and `metadata`, `file_path`, `cdn_url`, `extraction_error`, analysis
* version/processor and the `recording` / `work` embeds — storage internals
* not exposed over MCP. See queryAudioFiles for the measurement.
*/
export function toAudioListItem(row) {
if (!row || typeof row !== "object")
return {};
const src = row;
const out = {};
for (const k of AUDIO_LIST_FIELDS) {
if (src[k] !== undefined)
out[k] = src[k];
}
return out;
}
/**
* The `identification` section of pica_audio_inspect — what ACRCloud said this
* file is. Built from the single-file GET, which already carries the
* audio_analysis_results satellite flattened onto the row (findById in
* lib/services/audio-files/queries.ts selects `audio_analysis_results(*)`), so
* the section costs no extra call.
*
* Before 2026-09-26 no read tool exposed these columns: the only way an agent
* saw a match was the full row echoed by pica_audio_update, which #2360
* slimmed — leaving them unreachable (ops issue 3cf19a67).
*
* Deliberately lean: each DSP entry keeps only track_id + link (artwork,
* preview URLs and artist genres are dropped), and the ACRCloud AI-detection
* payload is cut to its headline fields (the per-stem and per-generator
* probability arrays are left out). Values are copied as stored, never
* rescaled: `identification_confidence` is ACRCloud's match score as written
* (25-100 across prod rows on 2026-09-26). `ai_detection.confidence` is the
* AI probability on 0-100 (the AiDetectionAcrcloud type). Its prod values
* span 0.2-96.47, which looked like mixed scales, but read against the
* verdict on 2026-09-26 (723 rows, one model_id): every `ai_generated` row is
* ≥ 61.91 and every `human` row ≤ 47.71, and all 363 values ≤ 1 are `human`
* — one 0-100 scale, with human tracks scoring near zero. A file never run
* through ACRCloud reports `identification_status: null`.
*/
export function toIdentificationSection(file) {
const src = file && typeof file === "object" ? file : {};
const asObject = (v) => v && typeof v === "object" && !Array.isArray(v)
? v
: null;
const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
const field = (k) => src[k] ?? null;
const externalIds = asObject(src.identification_external_ids) ?? {};
const dspRaw = asObject(src.identification_dsp_ids);
let dspIds = null;
if (dspRaw) {
dspIds = {};
for (const [platform, entry] of Object.entries(dspRaw)) {
const e = asObject(entry);
if (!e)
continue;
dspIds[platform] = { track_id: e.track_id ?? null, link: e.link ?? null };
}
}
const ai = asObject(src.ai_detection_acrcloud);
const aiDetection = ai
? {
is_ai_generated: ai.is_ai_generated ?? null,
confidence: ai.confidence ?? null,
likely_source: ai.likely_source ?? null,
model_id: ai.model_id ?? null,
}
: null;
return {
identification_status: field("identification_status"),
identification_source: field("identification_source"),
identification_completed_at: field("identification_completed_at"),
confidence: field("identification_confidence"),
acrid: field("acrid"),
identified_title: field("identified_title"),
identified_artist: field("identified_artist"),
identified_album: field("identified_album"),
identified_label: field("identified_label"),
identified_release_date: field("identified_release_date"),
isrc: strings(externalIds.isrc),
upc: strings(externalIds.upc),
iswc: strings(externalIds.iswc),
dsp_ids: dspIds,
ai_detection: aiDetection,
};
}
/**
* The one-line summary of a matched pica_audio_identify.
*
* The route (app/api/admin/audio-files/[id]/identify) returns the service's
* IdentificationResult FLAT — `title`, `artist`, `confidence`,
* `externalIds.isrc[]` at the top level. Until 2026-09-26 this tool read a
* `result.match` object the route has never sent, so a real match never
* reached this branch; and it multiplied `confidence` by 100, which only a
* 0-1 score would need. ACRCloud's music score is 0-100 — every stored
* `audio_analysis_results.identification_confidence` on prod reads 25-100
* (2026-09-26) — so it is printed as-is.
*/
export function describeIdentifyMatch(result) {
const r = result && typeof result === "object"
? result
: {};
const ext = r.externalIds && typeof r.externalIds === "object"
? r.externalIds
: {};
const isrcs = Array.isArray(ext.isrc)
? ext.isrc.filter((x) => typeof x === "string")
: [];
const confidence = typeof r.confidence === "number"
? `${Math.round(r.confidence)}/100`
: "not reported";
const title = typeof r.title === "string" ? r.title : "untitled";
const artist = typeof r.artist === "string" ? r.artist : "unknown artist";
return (`Match found: "${title}" by ${artist} (ACRCloud score ${confidence}). ` +
`ISRC: ${isrcs.length ? isrcs.join(", ") : "none returned"}. ` +
"If the file is linked to a work, metadata has been propagated.");
}
export class AudioFilesTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
return [
{
definition: {
name: "pica_audio_query",
scope: "read:files",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Find audio files — filter by name/title (`query`), work (`work_id`, or `work_ids` for several at once), file type, or analysis status. " +
"Rows are a LEAN list shape (identity, size, duration, bpm/key/energy/mood, links, bucket). For one file, pica_audio_inspect adds sample rate, bit depth, danceability (sections: ['analysis']) and the waveform (sections: ['waveform']); the raw `metadata` jsonb, storage path and CDN URL are storage internals not exposed over MCP. " +
"→ then: pica_audio_inspect (details for a specific file), " +
"pica_audio_assign (attach an unassigned file to a work/recording). " +
"Set unassigned:true to find orphan audio (no work/recording attached yet).",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
work_id: {
type: "string",
description: "Filter by work ID to get all audio files for a specific work",
},
work_ids: {
type: "array",
items: { type: "string" },
maxItems: AUDIO_QUERY_MAX_WORK_IDS,
description: `Audio for several works in ONE call (up to ${AUDIO_QUERY_MAX_WORK_IDS} work UUIDs) — e.g. every song in a playlist. Includes files attached through a recording of those works. Pages with limit/offset like the rest of the list; combine with file_type (e.g. 'master') to narrow to that kind of file.`,
},
file_type: {
type: "string",
description: "Filter by file type",
enum: ["master", "instrumental", "stem", "demo", "version"],
},
unprocessed: {
type: "boolean",
description: "Set true to get only files without audio analysis (no BPM/key detection yet)",
},
unassigned: {
type: "boolean",
description: "Set true to find audio the user uploaded that isn't attached to any work or recording yet — then attach it with pica_audio_assign",
},
query: {
type: "string",
description: "Filter by filename or title (case-insensitive substring). Applies to ALL results, not only unassigned ones — use it to find a specific file by name instead of paging the whole library, which truncates past the 100KB response cap.",
},
limit: {
type: "number",
description: "Maximum results to return (default: 50)",
},
offset: {
type: "number",
description: "Pagination offset — pass next_offset from the previous response to fetch the next page (default 0).",
},
},
},
},
executor: this.queryAudioFiles.bind(this),
},
{
definition: {
name: "pica_audio_inspect",
scope: "read:files",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Get details for a specific audio file, including its stored " +
'transcript (sections: ["transcript"]) even when the file is not ' +
"linked to any work, and what ACRCloud identified it as " +
'(sections: ["identification"] — status, matched title/artist/' +
"album/label, ISRC/UPC, DSP ids, confidence, AI-detection " +
"summary). Omit sections for everything. " +
"→ then: pica_audio_analyze (run analysis), pica_audio_identify (fingerprint match), " +
"pica_audio_assign (link an orphaned file to its work)",
workflows: ["audio-upload-required"],
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Audio file ID",
},
sections: {
type: "array",
items: {
type: "string",
enum: [
"basic",
"analysis",
"waveform",
"status",
"transcript",
"identification",
],
},
description: "Which data sections to include. Omit for all sections. " +
"`transcript` returns the stored speech-to-text transcript " +
"for this file whether or not it is linked to a work — the " +
"only way to read one that has not been copied onto a work's " +
"lyrics. `identification` returns the stored ACRCloud " +
"result; identification_status null means the file was " +
"never identified (run pica_audio_identify).",
},
},
required: ["id"],
},
},
executor: this.inspectAudioFile.bind(this),
},
{
definition: {
name: "pica_audio_analyze",
scope: "write:files",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Trigger audio analysis on an audio file. Two analysis types available:\n" +
"• Audio features (BPM, key, energy, valence, mood, duration)\n" +
"• Lyrics transcription (speech-to-text + language detection)\n\n" +
"Both are enabled by default. Analysis runs asynchronously — use pica_audio_inspect with status section to check progress. " +
"Transcribed lyrics are saved to the transcriptions table and auto-copied to the work's lyrics field when it is empty (existing lyrics are never overwritten). " +
"→ then: pica_audio_inspect (check progress), pica_search_for_sync (find sync-ready works)",
workflows: ["audio-upload-required"],
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Audio file ID to analyze",
},
force: {
type: "boolean",
description: "Force re-analysis even if the file was already analyzed",
},
enable_audio: {
type: "boolean",
description: "Enable audio feature extraction (BPM, key, energy). Default: true.",
},
enable_lyrics: {
type: "boolean",
description: "Enable lyrics transcription. Default: true.",
},
},
required: ["id"],
},
},
executor: withBillingGate(this.pica, "audio analysis", this.analyzeAudioFile.bind(this)),
},
{
definition: {
name: "pica_audio_presigned_upload",
scope: "write:files",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Generate a signed URL for uploading an audio file directly to storage. Returns an upload URL and upload ID. After uploading the file via HTTP PUT to the returned URL, call pica_audio_complete_upload to finalize. Supported formats: WAV, MP3, FLAC, AIFF, OGG, WebM. Max size: 800MB. " +
"→ then: pica_audio_complete_upload (finalize upload), pica_audio_analyze (extract BPM, key, mood). " +
"TIP: if you are in ChatGPT or another external client, users cannot upload audio " +
"through the chat. Direct them to https://withpica.com/inspect where they can " +
"drag and drop audio files (it auto-detects format and runs analysis). " +
"If the user insists on using this tool, ask for the filename and file size — " +
"you can detect the format from the file extension, no need to ask for content type.",
workflows: ["audio-upload-required"],
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
filename: {
type: "string",
description: "Original filename with extension (e.g. 'my-song.wav')",
},
content_type: {
type: "string",
description: "MIME type — detect from the filename extension: " +
".wav→audio/wav, .mp3→audio/mpeg, .flac→audio/flac, " +
".aiff/.aif→audio/aiff, .ogg→audio/ogg, .webm→audio/webm. " +
"Do not ask the user for this — work it out from the filename.",
enum: [
"audio/wav",
"audio/x-wav",
"audio/mpeg",
"audio/mp3",
"audio/flac",
"audio/x-flac",
"audio/aiff",
"audio/x-aiff",
"audio/ogg",
"audio/webm",
],
},
file_size: {
type: "number",
minimum: 1,
description: "Exact file size in bytes (> 0). Never send 0 or a guess — if you cannot read the file, you cannot upload it from here; send the user to withpica.com/inspect.",
},
work_id: {
type: "string",
description: "Optional work ID to link the audio file to. If provided, a recording will be auto-created for master files.",
},
title: {
type: "string",
description: "Optional title for the audio file. Defaults to filename without extension.",
},
file_type: {
type: "string",
description: "Type of audio file. Set it when you know it. If omitted, PICA reads the filename (instrumental / -inst / karaoke / playback / -pa → instrumental; stem(s) → stem; demo → demo; remix / radio edit / (live) / acoustic → version); with no signal in the name it stays 'master'.",
enum: ["master", "instrumental", "stem", "demo", "version"],
},
},
required: ["filename", "content_type", "file_size"],
},
},
executor: withBillingGate(this.pica, "audio upload", this.presignedUpload.bind(this)),
},
{
definition: {
name: "pica_audio_complete_upload",
scope: "write:files",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Finalize an audio file upload after the file has been PUT to the signed URL from pica_audio_presigned_upload. Creates the database record and optionally links to a work/recording. For master files linked to a work, a recording is auto-created if none exists. " +
"After several mutations, surface a pica_dashboard_briefing to the user. " +
"→ then: pica_audio_analyze (extract BPM, key, mood), pica_audio_identify (fingerprint match via ACRCloud)",
workflows: ["audio-upload-required"],
vernacular_kind: "internal",
vernacular_reason: "Invoked after pica_audio_presigned_upload PUT completes; finalizes the audio file record.",
inputSchema: {
type: "object",
properties: {
upload_id: {
type: "string",
description: "The uploadId returned from pica_audio_presigned_upload",
},
key: {
type: "string",
description: "The storage key returned from pica_audio_presigned_upload",
},
bucket: {
type: "string",
description: "The bucket returned from pica_audio_presigned_upload",
},
filename: {
type: "string",
description: "Original filename",
},
content_type: {
type: "string",
description: "MIME type of the uploaded file",
},
file_size: {
type: "number",
description: "File size in bytes",
},
work_id: {
type: "string",
description: "Optional work ID to link the audio file to",
},
title: {
type: "string",
description: "Optional title for the audio file",
},
file_type: {
type: "string",
description: "Type of audio file. Set it when you know it. If omitted, PICA reads the filename (instrumental / -inst / karaoke / playback / -pa → instrumental; stem(s) → stem; demo → demo; remix / radio edit / (live) / acoustic → version); with no signal in the name it stays 'master'.",
enum: ["master", "instrumental", "stem", "demo", "version"],
},
stem_label: {
type: "string",
description: "Label for stem files (e.g. 'vocals', 'drums', 'bass')",
},
version_label: {
type: "string",
description: "Label for version files (e.g. 'radio edit', 'extended mix')",
},
recording_id: {
type: "string",
description: "Link to an existing recording ID instead of auto-creating one",
},
classification: {
type: "string",
description: "Audio classification (e.g. 'original', 'remix', 'cover', 'live')",
},
},
required: [
"upload_id",
"key",
"bucket",
"filename",
"content_type",
"file_size",
],
},
},
executor: withBillingGate(this.pica, "audio upload finalization", this.completeUpload.bind(this)),
},
{
definition: {
name: "pica_audio_identify",
scope: "write:files",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Identify a track using ACRCloud audio fingerprinting. Returns matched metadata (title, artist, album, ISRC, label) with confidence score. If matched, metadata is automatically propagated to the linked work. " +
"For BPM, key, mood or lyrics rather than a fingerprint match, use pica_audio_analyze.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Audio file ID to identify",
},
force: {
type: "boolean",
description: "Force re-identification even if already matched",
},
},
required: ["id"],
},
},
executor: withBillingGate(this.pica, "audio identification", this.identifyAudioFile.bind(this)),
},
// ADR-232 § Decision 4 — operational read of the audio analysis
// pipeline state. Lets agents triage stuck / failed audio without
// manually paginating audio_files and joining the satellite.
{
definition: {
name: "pica_audio_pipeline_status",
scope: "read:files",
audiences: ["stdio", "http"],
tier: "read",
description: "Use when the user asks: 'what's stuck in audio analysis?', 'why hasn't this audio been analysed?', 'show me the audio pipeline status'. " +
"Returns the audio-analysis pipeline state for the calling org: counts per state (pending / complete / failed / stuck_over_24h) plus up to 5 sample rows per non-complete bucket. " +
"stuck_over_24h is pending audio that has been waiting more than 24h — the operational signal that the analysis worker has fallen behind. " +
"→ then: pica_audio_inspect (drill into a specific stuck file), pica_audio_analyze (force a retry on a failed file)",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
window_days: {
type: "number",
description: "Optional lookback window in days. Defaults to 7. Integer between 1 and 365.",
},
},
required: [],
},
nextSteps: [
{
tool: "pica_audio_inspect",
reason: "Drill into a specific stuck or failed audio file from the samples list.",
when: "on_success",
},
{
tool: "pica_audio_analyze",
reason: "Force a re-analyse on a failed audio file once the cause is understood.",
when: "on_success",
},
],
},
executor: this.audioPipelineStatus.bind(this),
},
{
definition: {
name: "pica_audio_assign",
scope: "write:files",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Attach an uploaded audio file to a work and/or a recording. Find unattached audio first with pica_audio_query unassigned:true. Pass at least one of work_id / recording_id. Reversible.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist catalog-linking action in the audio assignment flow, outside the Creator Question Atlas.",
annotations: { title: "assign audio" },
inputSchema: {
type: "object",
properties: {
audio_file_id: {
type: "string",
description: "the audio file to attach",
},
work_id: {
type: "string",
description: "attach to this work",
},
recording_id: {
type: "string",
description: "attach to this recording",
},
},
required: ["audio_file_id"],
},
},
executor: this.assignAudio.bind(this),
},
{
definition: {
name: "pica_audio_bulk_assign_suggested",
scope: "write:files",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Attach ALL orphan audio files whose suggested match is high-confidence " +
"(ISRC-verified, non-ambiguous) in ONE call — instead of looping " +
"pica_audio_assign per file. Pairings are recomputed server-side at " +
"execution time: nothing is sent, stale suggestions can't be applied, " +
"dismissed pairs stay excluded, and title-only ('possible') matches are " +
"NEVER bulk-attached. Returns counts (attached / skipped_ambiguous / " +
"skipped_possible / failed) + the attached audio file ids. Review the " +
"remainder with pica_audio_query unassigned:true, attach individually " +
"with pica_audio_assign, or reject a bad suggestion with " +
"pica_audio_dismiss_match. Reversible per file (reassign or unassign).",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist catalog-linking action in the audio assignment flow, outside the Creator Question Atlas.",
annotations: { title: "bulk attach suggested audio" },
inputSchema: {
type: "object",
properties: {
dry_run: {
type: "boolean",
description: "When true, returns { dry_run: true, attached (= would_attach), skipped_ambiguous, skipped_possible, attached_ids } without attaching anything — the SAME server-side classification runs, so the preview can't drift from the real run. Use to preview the bulk before committing.",
},
},
},
previewMode: "dry_run",
},
executor: this.bulkAssignSuggested.bind(this),
},
{
definition: {
name: "pica_audio_dismiss_match",
scope: "write:files",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Reject a suggested (audio file → recording) match so the pair never " +
"resurfaces — in suggestions, in the review queue, or in " +
"pica_audio_bulk_assign_suggested. The audio file stays an orphan and " +
"can still be attached manually (pica_audio_assign) or to a different " +
"recording; only THIS pairing is suppressed. Repeating a dismissal is a " +
"no-op success.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist catalog-linking action in the audio assignment flow, outside the Creator Question Atlas.",
annotations: { title: "dismiss audio match" },
inputSchema: {
type: "object",
properties: {
audio_file_id: {
type: "string",
description: "Audio file ID the suggestion is for",
},
recording_id: {
type: "string",
description: "Recording ID of the suggested match to reject",
},
},
required: ["audio_file_id", "recording_id"],
},
},
executor: this.dismissMatch.bind(this),
},
{
definition: {
name: "pica_audio_delete",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "destructive",
description: "Delete an audio file. Removes the file record AND its underlying " +
"storage object (the bucket object is cleaned up too — no orphan is " +
"left behind). Analysis and transcription satellites are removed " +
"with it. This does not delete the parent work or recording — only " +
"the audio file itself. To re-point a wrongly attached file instead " +
"of destroying it, use pica_audio_assign.",
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: "Audio file ID",
},
},
required: ["id"],
},
},
executor: withBillingGate(this.pica, "audio file deletion", this.deleteAudioFile.bind(this)),
},
{
definition: {
name: "pica_audio_update",
scope: "write:files",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Update metadata on an already-uploaded audio file: set its file_type " +
"(master/instrumental/stem/demo/version), stem_label (e.g. 'drums', " +
"'bass', 'lead vocal'), version_label, or title. Use this to tag a stem " +
"or correct a file_type after upload (previously only possible at upload " +
"time). To attach the file to a work/recording use pica_audio_assign " +
"instead. Reversible.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist catalog-metadata action in the audio management flow, outside the Creator Question Atlas.",
annotations: { title: "update audio" },
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "the audio file to update",
},
file_type: {
type: "string",
description: "set the file type",
enum: ["master", "instrumental", "stem", "demo", "version"],
},
stem_label: {
type: "string",
description: "label for a stem (e.g. 'drums', 'bass', 'lead vocal'); pass an empty string to clear",
},
version_label: {
type: "string",
description: "label for a version/alternate (e.g. 'radio edit', 'acoustic'); pass an empty string to clear",
},
title: {
type: "string",
description: "set the audio file's title",
},
},
required: ["id"],
},
},
executor: this.updateAudio.bind(this),
},
{
definition: {
name: "pica_audio_bulk_update",
scope: "write:files",
audiences: ["stdio", "http", "public"],
tier: "write",
description: `Update metadata on up to ${AUDIO_BULK_UPDATE_MAX_ITEMS} audio files in ONE call — each item sets any of ` +
"file_type (master/instrumental/stem/demo/version), stem_label, version_label, title for one file id. " +
"Use instead of looping pica_audio_update, e.g. to tag every file in a playlist as master or label a " +
"set of stems. Only this organisation's files are touched. Returns one lean result per item — " +
"updated, or failed with a reason (unknown id, bad file_type, nothing to set) — and never the file " +
"rows; a bad item does not stop the others. To attach files to a work/recording use pica_audio_assign. " +
"Reversible (update again). Pass dry_run:true to preview.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist catalog-metadata action in the audio management flow, outside the Creator Question Atlas.",
annotations: { title: "bulk update audio" },
inputSchema: {
type: "object",
properties: {
items: {
type: "array",
minItems: 1,
maxItems: AUDIO_BULK_UPDATE_MAX_ITEMS,
description: `One entry per audio file (max ${AUDIO_BULK_UPDATE_MAX_ITEMS}). Each needs id plus at least one field to set.`,
items: {
type: "object",
properties: {
id: {
type: "string",
description: "the audio file to update",
},
file_type: {
type: "string",
enum: [
"master",
"instrumental",
"stem",
"demo",
"version",
],
description: "set the file type",
},
stem_label: {
type: "string",
description: "label for a stem (e.g. 'drums'); pass an empty string to clear",
},
version_label: {
type: "string",
description: "label for a version (e.g. 'radio edit'); pass an empty string to clear",
},
title: {
type: "string",
description: "set the audio file's title",
},
},
required: ["id"],
},
},
dry_run: {
type: "boolean",
description: "When true, validates every item and checks each id exists in this organisation, returning would_update / failed per item without writing anything.",
},
},
required: ["items"],
},
previewMode: "dry_run",
},
executor: this.bulkUpdateAudio.bind(this),
},
];
}
async assignAudio(args) {
if (!args.work_id && !args.recording_id) {
return {
content: [
{
type: "text",
text: "provide at least one of work_id or recording_id to attach the audio",
},
],
isError: true,
};
}
// Cast to the real update signature — worktree resolves @withpica/mcp-sdk
// via the parent repo's node_modules (stale dist). CI builds the SDK fresh.
const audioFiles = this.pica.audioFiles;
try {
const updated = await audioFiles.update(args.audio_file_id, {
work_id: args.work_id,
recording_id: args.recording_id,
});
// The route returns the full row — waveform_data and the flattened
// analysis satellite, ~8k tokens a file (ops issue 5c53049a). An agent
// needs to see the link it made, not the waveform: project to the same
// lean shape a pica_audio_query row has.
return formatSuccess("Audio assigned", toAudioListItem(updated));
}
catch (err) {
// resolve-any-ID recovery (WS5 tail): agents holding a bare id often
// don't know whether it's a work or a recording; a mis-slotted id
// fails the FK with an opaque error. Probe what the id actually is
// and say so — response-only, best-effort, error path only.
const hint = await this.wrongIdTypeHint(args);
if (hint) {
return { content: [{ type: "text", text: hint }], isError: true };
}
throw err;
}
}
/**
* When an assign failed and one of the passed ids exists as the OTHER
* entity type, name the mistake precisely. Returns null when the probe
* finds nothing (the original error is rethrown by the caller).
*/
async wrongIdTypeHint(args) {
const probes = [
{ passedAs: "work_id", id: args.work_id, other: "recording" },
{ passedAs: "recording_id", id: args.recording_id, other: "work" },
];
for (const { passedAs, id, other } of probes) {
if (typeof id !== "string" || id.length === 0)
continue;
const exists = await (other === "recording"
? this.pica.recordings.get(id)
: this.pica.works.get(id)).then(() => true, () => false);
if (exists) {
return (`assign failed: ${passedAs}="${id}" is actually a ${other.toUpperCase()} id — ` +
`pass it as ${other}_id instead.`);
}
}
return null;
}
async bulkAssignSuggested(args) {
// Cast to the real signature — worktree resolves @withpica/mcp-sdk via
// the parent repo's node_modules (stale dist). CI builds the SDK fresh.
const audioFiles = this.pica.audioFiles;
const dryRun = args?.dry_run === true;
const result = await audioFiles.bulkAssignSuggested(dryRun ? { dry_run: true } : undefined);
const verb = dryRun ? "Would attach" : "Attached";
const summary = result.attached === 0
? `No high-confidence matches to attach (${result.skipped_ambiguous} ambiguous, ` +
`${result.skipped_possible} title-only skipped). Review the queue with ` +
`pica_audio_query unassigned:true and attach individually with pica_audio_assign.`
: `${verb} ${result.attached} audio file${result.attached === 1 ? "" : "s"} ` +
`(${result.skipped_ambiguous} ambiguous + ${result.skipped_possible} title-only ` +
`skipped, ${result.failed} failed).` +
(dryRun
? " Nothing was persisted — re-run without dry_run to commit."
: "");
return formatSuccess(summary, result);
}
async dismissMatch(args) {
const audioFiles = this.pica.audioFiles;
await audioFiles.dismissMatch(args.audio_file_id, args.recording_id);
return formatSuccess("Match dismissed — this audio → recording pairing will not be suggested again. " +
"The file stays unassigned (attach it elsewhere with pica_audio_assign).", {
audio_file_id: args.audio_file_id,
recording_id: args.recording_id,
dismissed: true,
});
}
async updateAudio(args) {
// Build the patch from only the fields the caller actually supplied, so an
// omitted field is left untouched (and an explicit "" clears a label).
const body = {};
if (args.file_type !== undefined)
body.file_type = args.file_type;
if (args.stem_label !== undefined)
body.stem_label = args.stem_label;
if (args.version_label !== undefined)
body.version_label = args.version_label;
if (args.title !== undefined)
body.title = args.title;
if (Object.keys(body).length === 0) {
return {
content: [
{
type: "text",
text: "provide at least one of file_type, stem_label, version_label or title to update",
},
],
isError: true,
};
}
// Cast to the real update signature — worktree resolves @withpica/mcp-sdk
// via the parent repo's node_modules (stale dist). CI builds the SDK fresh.
const audioFiles = this.pica.audioFiles;
const updated = await audioFiles.update(args.id, body);
// Lean row, not the full record — see assignAudio (ops issue 5c53049a).
return formatSuccess("Audio updated", toAudioListItem(updated));
}
async bulkUpdateAudio(args) {
const items = args.items;
if (!Array.isArray(items) || items.length === 0) {
return {
content: [
{
type: "text",
text: "items must be a non-empty array of { id, file_type?, stem_label?, version_label?, title? }",
},
],
isError: true,
};
}
if (items.length > AUDIO_BULK_UPDATE_MAX_ITEMS) {
return {
content: [
{
type: "text",
text: `at most ${AUDIO_BULK_UPDATE_MAX_ITEMS} items per call (got ${items.length}) — split the batch`,
},
],
isError: true,
};
}
const dryRun = args.dry_run === true;
const result = await this.pica.audioFiles.bulkUpdate(items, dryRun ? { dry_run: true } : undefined);
const verb = dryRun ? "Would update" : "Updated";
const summary = `${verb} ${result.updated} of ${result.requested} audio file${result.requested === 1 ? "" : "s"}` +
(result.failed > 0
? ` — ${result.failed} failed (see results[].reason).`
: ".") +
(dryRun ? " Nothing was written — re-run without dry_run to apply." : "");
return formatSuccess(summary, result);
}
async deleteAudioFile(args) {
// AudioFilesResource.delete is added in this change's SDK update. In an
// isolated worktree, mcp-server resolves @withpica/mcp-sdk via the parent
// repo's node_modules (a known worktree gotcha), so the freshly-built local
// dist isn't seen by tsc here. Cast to the real signature — CI builds the
// SDK fresh and typechecks this without the cast. (Not @ts-expect-error,
// which would become an unused-directive error once the dist is current.)
const audioFiles = this.pica.audioFiles;
const outcome = await audioFiles.delete(args.id);
return formatDeletion("audio file", args.id, outcome);
}
async queryAudioFiles(args) {
const raw = await this.pica.audioFiles.list({
work_id: args.work_id,
work_ids: Array.isArray(args.work_ids) ? args.work_ids : undefined,
file_type: args.file_type,
unprocessed: args.unprocessed,
unassigned: args.unassigned,
query: args.query,
limit: args.limit,
offset: args.offset,
});
// The REST route returns { success, audioFiles, count, pagination }; the
// SDK's generic unwrap (data.data || data) hands back the whole envelope
// for this route (its list key is `audioFiles`, not `data`). Normalise to
// the file array so count/total are correct and each orphan file's
// probable_match (added to the unassigned response) surfaces cleanly at
// items[].probable_match.
const files = Array.isArray(raw)
? raw
: (raw?.audioFiles ?? []);
// Neither audio-files list branch (workId/no-workId via getAudioFiles, or
// unassigned via findUnassigned) has a total row count — both report
// `pagination.hasMore` from an authoritative limit+1 DB probe instead.
// pageEnvelope's own no-total fallback (`returned >= limit`) over-reports
// has_more on an exact-boundary last page (returned === limit, nothing
// left) — the route's probe doesn't have that blind spot, so trust it
// whenever the envelope carries one. A bare-array response (defensive
// path, no route envelope) has no pagination to trust, so the fallback
// stands.
const pagination = Array.isArray(raw)
? undefined
: raw?.pagination;
const limit = args.limit ?? DEFAULT_AUDIO_QUERY_LIMIT;
const offset = args.offset ?? 0;
const page = pageEnvelope({ limit, offset, returned: files.length });
if (pagination) {
page.has_more = pagination.hasMore;
page.next_offset = pagination.hasMore ? offset + files.length : null;
}
// ⚠️ LIST rows carry only what a list is for. The route returns the full
// row with the analysis satellite flattened in — including
// `waveform_data`, ~4.9KB per file on prod against ~1.6KB for the rest of
// the row put together — plus `metadata` jsonb and recording/work embeds.
// At ~8KB an item the 100KB response guard trimmed the page to 8 files,
// which the reporter of ops issue 525585e8 read as "the same first 8
// items regardless of filters". Paging works (the guard hands back
// next_offset); it just should not have been needed at 8. Waveform and
// the extra analysis scalars are on `pica_audio_inspect` (sections
// "waveform" / "analysis"); `metadata`, `file_path`, `cdn_url` and the
// recording/work embeds are storage internals and are not exposed over
// MCP at all — say so rather than point at a call that lacks them.
const items = files.map((f) => toAudioListItem(f));
return formatList(items, { total: items.length, page });
}
async inspectAudioFile(args) {
const sections = args.sections;
const includeAll = !sections || sections.length === 0;
const includeBasic = includeAll || sections.includes("basic");
const includeAnalysis = includeAll || sections.includes("analysis");
// Waveform is heavy (~4.9KB/file on prod) and opt-in even on inspect —
// it is NOT part of the default (all-sections) response.
const includeWaveform = !!sections && sections.includes("waveform");
const includeStatus = includeAll || sections.includes("status");
const includeTranscript = includeAll || sections.includes("transcript");
const includeIdentification = includeAll || sections.includes("identification");
const result = {};
// Basic, analysis, waveform and identification all come from one get —
// the route flattens the analysis satellite onto the row.
if (includeBasic ||
includeAnalysis ||
includeWaveform ||
includeIdentification) {
const file = await this.pica.audioFiles.get(args.id);
if (includeIdentification) {
result.identification = toIdentificationSection(file);
}
if (includeWaveform) {
result.waveform = {
waveform_data: file.waveform_data ??
null,
};
}
if (includeBasic) {
// ADR-201 Phase 2: surface every field the write side accepts.
// pica_audio_complete_upload writes recording_id, stem_label,
// version_label, classification (4 of the 5 audit-flagged fields)
// directly to audio_files columns. The fifth — content_type —
// is transformed into audio_format on write (e.g. "audio/mpeg"
// → "mpeg"); already surfaced.
result.basic = {
id: file.id,
work_id: file.work_id,
recording_id: file.recording_id,
title: file.title,
filename: file.filename,
file_type: file.file_type,
stem_label: file.stem_label,
version_label: file.version_label,
classification: file.classification,
file_size: file.file_size,
audio_format: file.audio_format,
published: file.published,
created_at: file.created_at,
updated_at: file.updated_at,
};
}
if (includeAnalysis) {
result.analysis = {
duration_seconds: file.duration_seconds,
detected_bpm: file.detected_bpm,
detected_key: file.detected_key,
detected_energy: file.detected_energy,
detected_valence: file.detected_valence,
detected_mood: file.detected_mood,
// Moved off the LIST row on 2026-09-11 (ops 525585e8); this is now
// their only MCP home.
detected_danceability: file
.detected_danceability ?? null,
sample_rate: file.sample_rate ??
null,
bit_depth: file.bit_depth ??
null,
metadata_extracted: file.metadata_extracted,
};
}
}
// Fetch status separately
if (includeStatus) {
const status = await this.pica.audioFiles.getStatus(args.id);
result.status = status;
}
// The transcript, wherever it is. Before 2026-09-07 an agent could not
// read one at all unless it had already propagated to works.lyrics, which
// never happens for a file with no work — 218 of 558 transcriptions on
// prod. `has_transcript: false` is a normal answer; `reaches_work: false`
// says this text is visible nowhere else, which is the cue to link the
// file (pica_audio_assign), not to invent an association here.
if (includeTranscript) {
const transcript = await this.pica.audioFiles.getTranscript(args.id);
result.transcript = transcript;
}
return formatSuccess("Audio file details", result);
}
async analyzeAudioFile(args) {
const result = await this.pica.audioFiles.analyze(args.id, {
forceReAnalyze: args.force,
enableAudio: args.enable_audio,
enableLyrics: args.enable_lyrics,
});
return formatSuccess(result.message || "Analysis triggered", result.features);
}
async presignedUpload(args) {
const result = await this.pica.audioFiles.presignedUpload({
filename: args.filename,
contentType: args.content_type,
fileSize: args.file_size,
workId: args.work_id,
title: args.title,
fileType: args.file_type,
});
return formatSuccess(`Upload URL generated for "${args.filename}". PUT the file to the uploadUrl, then call pica_audio_complete_upload with the uploadId, key, bucket, and metadata to finalize.`, {
uploadUrl: result.uploadUrl,
uploadId: result.uploadId,
key: result.key,
bucket: result.bucket,
expiresAt: result.expiresAt,
metadata: result.metadata,
});
}
async completeUpload(args) {
const result = await this.pica.audioFiles.completeUpload({
uploadId: args.upload_id,
key: args.key,
bucket: args.bucket,
metadata: {
filename: args.filename,
contentType: args.content_type,
fileSize: args.file_size,
workId: args.work_id,
title: args.title,
fileType: args.file_type,
stemLabel: args.stem_label,
versionLabel: args.version_label,
recordingId: args.recording_id,
classification: args.classification,
},
});
const hints = [
{
gap: "audio_not_analysed",
suggestion: "Run pica_audio_analyze to extract BPM, key, energy, and mood",
severity: "important",
},
{
gap: "audio_not_identified",
suggestion: "Run pica_audio_identify to fingerprint and match against ACRCloud",
severity: "nice_to_have",
},
];
if (!args.work_id) {
hints.unshift({
gap: "audio_not_linked",
suggestion: "Link this audio file to a work",
severity: "critical",
});
}
return formatSuccess(`Upload finalized. Audio file created with ID: ${result.audioFileId}.`, result, hints);
}
async identifyAudioFile(args) {
const result = await this.pica.audioFiles.identify(args.id, {
force: args.force,
});
if (result.alreadyIdentified) {
return formatSuccess("Already identified — no credits charged. Use force: true to re-identify.", result);
}
else if (result.status === "matched") {
return formatSuccess(describeIdentifyMatch(result), result);
}
else if (result.status === "no_match") {
return formatSuccess("No match found in ACRCloud database. This may be an unreleased or very rare track.", result);
}
else {
return formatAsText(result);
}
}
/**
* ADR-232 § Decision 4 — pica_audio_pipeline_status executor.
* Forwards to the SDK; the service-layer aggregator does the work.
*/
async audioPipelineStatus(args) {
const windowDaysRaw = args.window_days;
let windowDays;
if (windowDaysRaw !== undefined && windowDaysRaw !== null) {
const parsed = Number(windowDaysRaw);
if (!Number.isFinite(parsed) ||
!Number.isInteger(parsed) ||
parsed < 1 ||
parsed > 365) {
return {
content: [
{
type: "text",
text: "window_days must be an integer between 1 and 365.",
},
],
isError: true,
};
}
windowDays = parsed;
}
const result = await this.pica.audioPipelineStatus.status({
window_days: windowDays,
});
const { pending, complete, failed, stuck_over_24h } = result.by_state;
const total = pending + complete + failed + stuck_over_24h;
const verdict = total === 0
? `No audio files in the last ${result.window_days} day(s)`
: `${total} audio file${total === 1 ? "" : "s"} in window: ${pending} pending, ${complete} complete, ${failed} failed, ${stuck_over_24h} stuck over 24h`;
return formatSuccess(verdict, result);
}
}
//# sourceMappingURL=audio-files.js.map