@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
1,809 lines • 101 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
/**
* Works Tools — MCP tools for managing musical works.
*
* ADR-174 Phase 2 item 2 reconciliation: the write surface covers only
* real columns on `public.works`. Seven phantom fields previously
* accepted via `additionalProperties: true` have been removed:
* - `label` (no column; recordings/releases store label via org FK)
* - `spotify_url` (lives on recordings/releases)
* - `album_name`, `album_release_date`, `album_art_url`, `track_number`,
* `upc` (all album/release metadata — handled via `pica_releases_*`)
*
* Five add-candidates surfaced per the audit at
* docs/audits/2026-04-14-mcp-db-reconciliation.md §1:
* tempo, key, display_artist, published, tunecode.
*
* Agents that need to set label metadata call `pica_releases_create`
* or `pica_releases_update` with `label_organization_id`, resolving
* the name via `pica_labels_query` first.
*/
import { ApiError, EntityNotFoundError, currentCatalogue, } from "@withpica/mcp-sdk";
import { formatProposedChange, readChangeOutcome, } from "./relationship-change.js";
import { formatSuccess, formatDeletion, formatStructuredList, buildDecision, mapGapsToHints, pushSectionError, } from "@withpica/mcp-utils";
import { buildCustodyHints } from "./custody-hints.js";
import { typedError } from "./error-envelope.js";
import { computeWorkGapHints } from "./gap-hints.js";
import { buildIntegrityPressure } from "./integrity-hints.js";
import { detectVersionOfExistingWork, buildVersionWarning, parseVersionTitle, } from "./version-guard.js";
import { WORK_TYPE_VALUES } from "./catalog-enums.js";
import { VERBOSE_PROPERTY, RECEIPT_NOTE, changedFields, newHintsOnly, slimIntegrity, takeVerbose, } from "./write-receipt.js";
/**
* Canonical write-schema for `works`. Every property below must
* correspond to a real column on `public.works` — verified against
* `lib/db/database.types.ts`. Keep this list tight: reintroducing a
* phantom field is the exact regression ADR-174 Phase 2 exists to
* prevent. The HTTP route at `app/api/admin/works/[id]/route.ts`
* carries a broader allow-list that includes admin-UI-posted satellite
* fields; this list is the narrower MCP-agent contract.
*/
export const WORK_WRITE_PROPERTIES = {
title: {
type: "string",
description: "Work title",
},
work_type: {
type: "string",
description: "Type of work",
enum: [...WORK_TYPE_VALUES],
},
work_status: {
type: "string",
description: "Work status (state machine validates transitions — demo → unreleased → released, etc.)",
enum: ["demo", "unreleased", "released", "pitched", "placed", "archived"],
},
iswc: {
type: "string",
description: "International Standard Musical Work Code (composition)",
},
isrc: {
type: "string",
description: "International Standard Recording Code. Bridges publishing (works) and masters (recordings).",
},
tunecode: {
type: "string",
description: "PRS tunecode (e.g. 851348GT)",
},
// ADR-282: replaces per-society columns (prs_work_id) with a generic array
// so no single society is privileged by a dedicated field.
society_registrations: {
type: "array",
description: "Society registrations for this work, e.g. [{ society_code: 'PRS', registered: true, external_id: 'GB123' }]. " +
"society_code identifies the collecting society (PRS, PPL, MCPS, MLC, GEMA, ASCAP, …); " +
"no society is privileged by a dedicated field.",
items: {
type: "object",
properties: {
society_code: {
type: "string",
description: "collecting society code, e.g. PRS, PPL, MLC, GEMA",
},
registered: {
type: "boolean",
description: "whether registered with this society",
},
external_id: {
type: "string",
description: "the society's identifier for this entity (optional)",
},
},
required: ["society_code", "registered"],
},
},
spotify_track_uri: {
type: "string",
description: "Spotify track URI (e.g. spotify:track:0b8ZtRm9u3xSzRazABeHFF).",
},
duration_seconds: {
type: "number",
description: "Duration in seconds",
},
tempo: {
type: "number",
description: "BPM (beats per minute)",
},
key: {
type: "string",
description: "Musical key (e.g. 'C major', 'Am')",
},
genre: {
type: "array",
items: { type: "string" },
description: "Genre tags (e.g. ['R&B', 'Soul'])",
},
mood: {
type: "array",
items: { type: "string" },
description: "Mood tags (e.g. ['Uplifting', 'Reflective']). Stored on the work's primary recording (audio traits), recording-grain.",
},
primary_artist: {
type: "string",
description: "Primary artist name (freetext display value)",
},
primary_artist_id: {
type: "string",
description: "Primary artist person ID (FK to people)",
},
disambiguation: {
type: ["string", "null"],
maxLength: 200,
description: "A short note that tells apart two DIFFERENT songs with the same title and primary artist — e.g. 'theme', '2019 song', 'live-only composition' (MusicBrainz's term). " +
"Set it ONLY when two genuinely different songs share title + artist; the cue is a WORK_ALREADY_EXISTS refusal for a song you know is not the existing one. " +
"Leave it unset otherwise — it is part of the duplicate check, so an invented note would let a real duplicate in. " +
"If the two are the same song, use or merge the existing work instead. Not a version label: recording versions (live, remix, acoustic) belong on pica_recordings_create. " +
"null or '' clears it.",
},
original_work_id: {
type: ["string", "null"],
description: "Set ONLY on a MODIFIED VERSION: the id of the work this one modifies (CWR version type MOD). " +
"A remix is by default NOT a separate work — it is a new recording of the same song (pica_recordings_create with work_id of the original and version_type 'remix', remixer credited on the recording). " +
"Only when the remix adds writers the original does not have (new lyrics, melody or section) is it its own work with its own ISWC and writer shares; then set original_work_id to the original's id. " +
"Must be a work in the same catalogue, not this work. null clears the link.",
},
display_artist: {
type: "string",
description: "Display-friendly artist string (e.g. 'Jane Doe ft. John Smith')",
},
publisher_name: {
type: "string",
description: "Publisher name (legacy freetext). Prefer linking via publisher identity where possible.",
},
lyrics: {
type: "string",
description: "Lyrics text",
},
lyrics_language: {
type: "string",
description: "Language of lyrics (ISO 639-1, e.g. 'en')",
},
published: {
type: "boolean",
description: "Whether the work is published on the public directory",
},
notes: {
type: "string",
description: "Internal notes. Setting this REPLACES the whole field — to add a line, use append_notes on pica_works_update instead.",
},
metadata: {
type: "object",
description: "Free-form JSONB snapshot stored on the work (e.g. a DAW session capture: tracks, devices, samples, tempo). Stored verbatim on works.metadata; not individually queried.",
},
// Declared jsonb extension points — ADR-174 Phase 2c inverse-phantom fix.
// These keys are not columns on public.works; the service routes them to
// the `work_licensing` satellite via extractSatelliteFields() (see
// lib/services/works/constants.ts WORK_SATELLITE_FIELDS). Same shape as
// the parallel fields on `pica_recordings_update` (recordings.ts:193).
ai_disclosure: {
type: "object",
description: "AI disclosure object (UK AI Copyright compliance). Fields: composition, performance, production, mastering ('none'|'assisted'|'generated'), tools_used (string[]), disclosure_date, disclosed_by.",
},
provenance_attestation: {
type: "object",
description: "Human creation proof. Fields: human_created (bool), attested_by, attested_at, attestation_method, supporting_evidence, ip_address, signature_hash, certificate_id.",
},
training_rights: {
type: "object",
description: "B2B ML licensing compliance object. Fields: available_for_training (bool), training_terms, minimum_fee, excluded_uses, permitted_uses, geographic_restrictions, requires_attribution, requires_notification, license_duration, updated_at, updated_by.",
},
};
/**
* Compact one-line orientation for the `dossier` section of pica_works_inspect.
* The full dossier rides `structuredContent.data.dossier`; this string just
* gives the agent the shape at a glance so it doesn't have to parse the JSON.
* Returns null when no dossier was assembled (section not requested, or the
* assembly failed and degraded to null).
*/
function summariseDossier(dossier) {
if (!dossier || typeof dossier !== "object")
return null;
const d = dossier;
const title = d.work?.title ?? "this work";
const count = (v) => (Array.isArray(v) ? v.length : 0);
const parts = [
`${count(d.recordings)} recording(s)`,
`${count(d.releases)} release(s)`,
`${count(d.compositionCredits)} composition credit(s)`,
`${count(d.people)} people`,
];
const attestation = d.provenance?.attestationStatus ??
(d.provenance?.hasProvenanceCertificate ? "certified" : "not attested");
const aiDisclosed = d.aiDisclosure?.disclosed ? "disclosed" : "not disclosed";
const consentState = d.consent?.workEffective?.state ?? "none";
const consentLevel = d.consent?.workEffective?.level
? ` (${d.consent.workEffective.level})`
: "";
const completeness = typeof d.work?.completenessScore === "number"
? `${d.work.completenessScore}%`
: "n/a";
return (`Dossier for "${title}": ${parts.join(", ")}. ` +
`Provenance: ${attestation}. AI disclosure: ${aiDisclosed}. ` +
`Consent: ${consentState}${consentLevel}. Completeness: ${completeness}. ` +
`(Full payload under structuredContent.data.dossier; PDF / multi-work ZIP: pica_export_song_dossiers.)`);
}
/**
* Page caps for the two health_filter branches of pica_works_query — the
* routes' own maxima (app/api/admin/quality/works-health/route.ts and
* .../completeness/low-score/route.ts). Hand-copied (this package cannot
* import the app); no test ties them — change both together.
*/
const WORKS_HEALTH_MAX_PAGE = 50;
const LOW_COMPLETENESS_MAX_PAGE = 200;
/**
* The pica_works_inspect sections whose route opens through a relationship.
* A hand-kept mirror of lib/auth/catalogue-routes.ts CATALOGUE_ROUTES (only
* GET /api/admin/works/:id there serves a single song's sections): opening
* another works sub-route there means adding its section here.
*/
const CLIENT_CATALOGUE_WORK_SECTIONS = ["basic"];
export class WorksTools {
pica;
constructor(pica) {
this.pica = pica;
}
/**
* Get all works tools
*/
getTools() {
return [
{
definition: {
name: "pica_works_query",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Find works by any criteria. Use when the user asks: 'what's " +
"pending registration?', 'unregistered works', 'works registered " +
"with ASCAP', 'least-complete works', 'works ready for outreach', " +
"'works with no statement data', 'most-streamed works'. Search " +
"by title, ISWC, or artist, or filter by " +
"registration status, the collection society a work is registered " +
"with (society_code, any territory), health, whether a collecting-" +
"society statement is linked (has_statement_data), completeness range " +
"(completeness_score_lt / completeness_score_gt; sort " +
"completeness_score:asc for least-complete first), or streaming " +
"activity (streaming_activity_gt / streaming_activity_lt; sort " +
"streaming_activity:desc for the most-active works). Each row " +
"carries its completeness score and a per-category breakdown " +
"(identifiers, registrations, metadata, platforms, " +
"external_benchmark), missing_field_count (count of unfilled " +
"completeness fields), and streaming_activity_score (max-rollup " +
"over recordings); sort missing_field_count:desc for the " +
"works with the most gaps. Narrow by date with created_from / " +
"created_to (ISO YYYY-MM-DD; for a year use 2013-01-01…2013-12-31), " +
"or by presence with has_iswc / has_publisher / has_lyrics " +
"(has_lyrics answers 'how many of my songs have lyrics?' — " +
"count from the returned total, do not sample a work). → then: " +
"pica_works_inspect (one work in detail), or load the " +
"register-my-works skill (the registration workflow)",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search by title, ISWC, or artist name. An artist name also finds works linked to that artist through a recording's artist or a credited person, even when the work's primary_artist is empty or spelled differently (appended after the direct matches, up to 300).",
},
health_filter: {
type: "string",
enum: ["needs_attention", "low_completeness"],
description: "Filter by health status — needs_attention (cross-org reconciliation problems: unreconciled composition/master chains, split discrepancies) or low_completeness (below completeness score threshold: missing ISWC, credits, etc.). Both honour limit/offset. low_completeness reports `total` = works below the threshold by their stored score (max 200 per page). needs_attention checks at most 50 works per call and `offset` counts works CHECKED, not rows returned — a page can hold fewer rows than limit while more remain, so continue from next_offset until it is null; its `total` is set only when one call from offset 0 checks every candidate, otherwise it is null and the answer is the rows counted across pages.",
},
has_statement_data: {
type: "boolean",
description: "Filter to works that have (true) or do not have (false) statement-derived data — derived from whether the work carries a collecting-society external id (any society, any territory).",
},
completeness_score_lt: {
type: "number",
description: "Return only works whose cached completeness score is strictly less than this value (0-100).",
},
completeness_score_gt: {
type: "number",
description: "Return only works whose cached completeness score is strictly greater than this value (0-100).",
},
missing_field_count_gt: {
type: "number",
description: "Return only works whose cached missing-field count is strictly greater than this (gap-finding). Pair with sort missing_field_count:desc for the works with the most gaps.",
},
missing_field_count_lt: {
type: "number",
description: "Return only works whose cached missing-field count is strictly less than this.",
},
streaming_activity_gt: {
type: "number",
description: "Return only works whose cached streaming activity score (max-rollup over recordings) is strictly greater than this. Pair with sort streaming_activity:desc for the most-active works.",
},
streaming_activity_lt: {
type: "number",
description: "Return only works whose cached streaming activity score is strictly less than this (surface low-activity catalog).",
},
sort: {
type: "string",
enum: [
"completeness_score:asc",
"completeness_score:desc",
"missing_field_count:asc",
"missing_field_count:desc",
"streaming_activity:asc",
"streaming_activity:desc",
],
description: "Order results by completeness score, missing-field count, or streaming activity. `:asc` returns least-complete / fewest-gaps / lowest-activity first; `:desc` returns most-complete / most-gaps / highest-activity first. Use missing_field_count:desc to surface the works with the most gaps; streaming_activity:desc for the most-played works.",
},
society_code: {
type: "string",
description: "Filter to works registered with a collection society, by its code (case-insensitive, e.g. 'ASCAP', 'PRS', 'PPL', 'MCPS', 'MLC', 'GEMA', 'SACEM'). Works for any territory's society — a US (ASCAP) work is found the same way as a UK (PRS) one. A work counts as registered with the society when it has a registration marked registered or carrying that society's external id.",
},
work_type: {
type: "string",
enum: [...WORK_TYPE_VALUES],
description: "Filter by work type. 'instrumental' answers 'my instrumentals'.",
},
work_status: {
type: "string",
enum: [
"demo",
"unreleased",
"released",
"pitched",
"placed",
"archived",
],
description: "Filter by work status. 'unreleased' answers 'unreleased songs'.",
},
genre: {
type: "string",
description: "Filter to works tagged with this genre (e.g. 'R&B'). Combine with available_for_licensing for 'R&B I can license'.",
},
mood: {
type: "string",
description: "Filter to works whose primary recording carries this mood tag.",
},
tempo_min: {
type: "number",
description: "Filter to works whose primary recording tempo (BPM) is at least this.",
},
tempo_max: {
type: "number",
description: "Filter to works whose primary recording tempo (BPM) is at most this.",
},
primary_artist: {
type: "string",
description: "Exact primary-artist name match. Misses works whose primary_artist is empty — use `query` to include works linked by recording artist or credits.",
},
artist: {
type: "string",
description: "Exact primary-artist name match (alias). Misses works whose primary_artist is empty — use `query` to include works linked by recording artist or credits.",
},
label: {
type: "string",
description: "Filter by derived label (primary recording label, else primary release parent_label).",
},
available_for_licensing: {
type: "boolean",
description: "Filter to works available (true) / not available (false) for licensing.",
},
release_format: {
type: "string",
enum: ["single", "ep", "album", "compilation"],
description: "Filter by the primary release's format.",
},
is_featured: {
type: "boolean",
description: "Filter to featured (true) / non-featured works.",
},
published: {
type: "boolean",
description: "Filter by directory-published state.",
},
created_from: {
type: "string",
description: "Only works added/composed on or after this ISO date (YYYY-MM-DD). For a year pass created_from=2013-01-01 & created_to=2013-12-31.",
},
created_to: {
type: "string",
description: "Only works added/composed on or before this ISO date (YYYY-MM-DD).",
},
has_iswc: {
type: "boolean",
description: "Filter to works that have (true) or lack (false) an ISWC.",
},
has_publisher: {
type: "boolean",
description: "Filter to works that have (true) or lack (false) a publisher_name.",
},
has_lyrics: {
type: "boolean",
description: "Filter to works that have (true) or lack (false) a stored lyric sheet (works.lyrics; blank counts as lacking). Use with limit:1 and read `total` to COUNT works with lyrics — never infer lyric coverage from a sampled work. Note works.lyrics_language is a separate tag and does not imply lyrics are present.",
},
limit: {
type: "number",
minimum: 1,
maximum: 100,
description: "Max results (default: 50)",
},
offset: {
type: "number",
description: "Pagination offset",
},
light: {
type: "boolean",
description: "Return a lightweight projection per work (id, title, primary_artist, iswc, tunecode, isrc, work_status, completeness_score, missing_field_count) instead of the full ~1KB row. Use for wide bulk reads — e.g. reconciling a PRO/CWR export against the catalogue — so a large page fits under the response cap. Use pica_works_inspect for full detail on a specific work.",
},
},
},
},
executor: this.queryWorks.bind(this),
},
{
definition: {
name: "pica_works_inspect",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Get the full picture on one work — its details, gaps, and " +
"quality data (the song itself, not its album or project). Use " +
"when the user asks: 'tell me about this song', 'show me this " +
"song', 'what do you know about this work?'. Omit sections to get " +
"everything. sections=['enrichment'] shows where each field came " +
"from, the completeness score and breakdown, and the status of " +
"any running lookups (Spotify, MLC, ACRCloud, audio analysis) in " +
"one response. sections=['dossier'] returns the full song dossier — " +
"everything PICA holds about the song (recordings, releases, " +
"credits, people, provenance, AI disclosure, consent) in one " +
"structured payload. " +
"To find works by title, ISWC, or status, use pica_works_query. " +
"→ then: " +
"pica_credits_update (fix credits), pica_resolve_work (fill " +
"identifiers + metadata), pica_run_work_cascade (retry)",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
// id/work_id/iswc are interchangeable entry points, so none can be
// JSON-Schema `required`; this lets pica_execute hard-reject an
// empty/stripped call with WRAPPED_ARGS_MISSING instead of a
// confusing "invalid id" domain error (feedback 6d516171). iswc
// resolves to the work server-side (in-app assistant roadblock
// 2026-07-05: models pass identifiers where an id is expected).
requiredOneOf: [["id", "work_id", "iswc"]],
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Work ID. Also accepted as work_id (alias for read-after-write flows from write tools that use work_id).",
},
work_id: {
type: "string",
description: "Work ID (alias for id — accepted because the write tools use work_id).",
},
iswc: {
type: "string",
description: "ISWC of the work — resolved to the work for you (e.g. T-034.524.680-1). Use when you have the code but not the id.",
},
sections: {
type: "array",
items: {
type: "string",
enum: [
"basic",
"credits",
"recordings",
"agreements",
"health",
"completeness",
"registration",
"score",
"provenance",
"field_provenance",
"enrichment",
"production_assets",
"releases",
"licensing",
"dossier",
],
},
description: 'Which data sections to include. Omit for all sections. Use ["basic"] for lightweight lookup. "health" is a short summary of THIS work\'s completeness (score, missing critical fields, suggestions) — opt-in, since "completeness" carries the full breakdown; for works across the catalogue needing attention use pica_works_query health_filter. "licensing" surfaces the AI-disclosure / training-rights / provenance-attestation compliance fields (work_licensing satellite) in one scoped view. "dossier" assembles the full song dossier (recordings, releases, credits, people, provenance, AI disclosure, consent) — heavy, so it is opt-in only (never part of the default all-sections response); for the PDF, or several works in one ZIP, use pica_export_song_dossiers.',
},
},
required: [],
},
// ADR-199 — work status state machine mirrored from
// lib/utils/state-machine.ts workStatusMachine. Build-step
// assertion (Phase D) catches drift between this map and the
// DB CHECK constraint on works.status.
validTransitionsFrom: {
demo: ["unreleased", "pitched", "archived"],
unreleased: ["released", "pitched", "archived"],
pitched: ["placed", "unreleased", "released", "archived"],
placed: ["released", "archived"],
released: ["archived"],
archived: ["unreleased", "demo"],
},
nextSteps: [
{
tool: "pica_credits_update",
reason: "Fix or fill writer/composer credits when the inspect surfaces gaps.",
when: "on_success",
},
{
tool: "pica_resolve_work",
reason: "Fill identifier gaps (ISWC, ISRC) and metadata via the enrichment cascade.",
when: "on_success",
},
],
},
executor: this.inspectWork.bind(this),
},
{
definition: {
name: "pica_works_create",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Add a new song to the catalog. Use when the user asks: 'add this " +
"song', 'create a new work', 'register this track'. Before " +
"saving, it's worth knowing: who wrote it — the user alone, or " +
"with others? If someone else co-wrote it, what's their role, and " +
"should PICA send it to them to verify? That sets custody and " +
"splits. And was the writing, performance, production, or " +
"mastering human-made, AI-assisted, or AI-generated? (Those four " +
"are set independently on ai_disclosure.) Always run " +
"pica_works_query first to check for a duplicate — two same-title " +
"works cause split and registration trouble. If a refusal says the " +
"work already exists but it really is a different song with the same " +
"title and artist, retry with disambiguation (e.g. 'theme'). A remix is " +
"usually a new recording of the original song (pica_recordings_create, " +
"version_type 'remix'), not a new work; only a remix that added writers " +
"is its own work, linked with original_work_id. Album, artwork, " +
"track number, UPC, and Spotify URL live on the release " +
"(pica_releases_create/update), not here; label too, via the " +
"release (resolve the label name with pica_labels_query first). " +
"→ before: pica_works_query (check duplicates) → then: " +
"pica_credits_update (add writers), pica_works_inspect (check " +
"gaps), pica_resolve_work (fill identifiers)",
workflows: ["work-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: {
...WORK_WRITE_PROPERTIES,
create_twin: {
type: "boolean",
description: "default true — creating a work also creates its linked master recording (and vice versa); pass false to create one grain only",
},
},
required: ["title", "work_type"],
},
nextSteps: [
{
tool: "pica_credits_update",
reason: "Add writer/composer credits to the new work — required before registration.",
when: "on_success",
},
{
tool: "pica_works_inspect",
reason: "Read back the created work to confirm fields persisted and surface any gaps.",
when: "on_success",
},
{
tool: "pica_agreement_types_create",
reason: "If this work is subject to a work-for-hire or producer agreement, create it now — terms set before registration avoid renegotiation later.",
when: "on_success",
},
],
},
executor: this.createWork.bind(this),
},
{
definition: {
name: "pica_works_update",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Update an existing musical work. Only real `works` columns are accepted — " +
"album and release metadata (album_name, album_art_url, track_number, upc, spotify_url) must go through pica_releases_create/update, not here. " +
"Writer credits and shares (collaborators) are not set here either — pica_credits_update (batch) or pica_credit_add write work_collaborators, the home every health check and export reads. " +
"Audio-trait fields (energy, vocal_type, available_for_licensing, danceability) live on a " +
"related table (a 'satellite' — extra fields off the main works row), not exposed through " +
"MCP; set them through a release or recording path. " +
"Three jsonb compliance fields (ai_disclosure, provenance_attestation, training_rights) route " +
"to that same work_licensing satellite table and ARE set-able here. " +
"original_work_id links a modified version (a remix that added writers) to the work it modifies. " +
"Answers with a compact receipt — the id, each field you sent with the value now stored, and only the gaps this write opened; pass verbose: true for the full work. " +
"→ then: pica_works_inspect (verify changes persisted)",
workflows: ["work-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: "Work ID (required)",
},
...WORK_WRITE_PROPERTIES,
...VERBOSE_PROPERTY,
append_notes: {
type: "string",
maxLength: 10000,
description: "Text to add to the end of the work's notes, on a new line, without reading or rewriting what is there. " +
"Safe when someone else edits the work at the same time (a conflict is retried, never overwritten). " +
"Cannot be sent together with notes.",
},
},
required: ["id"],
},
nextSteps: [
{
tool: "pica_works_inspect",
reason: "Read back the work after update to confirm every field round-tripped — silent persistence drops are real.",
when: "on_success",
},
],
},
executor: this.updateWork.bind(this),
},
{
definition: {
name: "pica_works_delete",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "destructive",
// ADR-257 WS-2 — delegated-agent (OAuth/grant) callers cannot hold
// destructive:* scope; they propose, the org owner approves out-of-band,
// PICA replays via human-approval-executor. Session + first-party-key
// callers fall through to the normal destructive path unchanged.
approvalMode: "human",
description: "Delete a musical work. It is automatically unlinked from any " +
"releases it appears on (the release keeps its track). If the work " +
"still has dependent records (recordings, royalty payments, version " +
"links, claims, signed split sheets) it is archived instead — hidden from the catalog but " +
"preserved — and the result tells you exactly what to remove to " +
"delete it permanently. A work with registry identifiers " +
"(ISRC/ISWC/MLC) is always archived, never destroyed. The result " +
"status is 'deleted' or 'archived'. " +
"Prefer pica_works_bulk_delete when removing several works in one call.",
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: "Work ID",
},
},
required: ["id"],
},
},
executor: this.deleteWork.bind(this),
},
{
definition: {
name: "pica_works_verify",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Writes nothing: PICA keeps no single 'verified' flag on a work. Returns " +
"NO_WORK_VERIFIED_FLAG naming what the person can confirm themselves, signed in, " +
"on /inspect — the human-made stamp, their own writer credit, their own master " +
"share. Not a registration check (that's pica_works_verify_registrations). To " +
"review a work, use pica_works_inspect.",
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: "Work UUID from pica_works_query.",
},
},
required: ["id"],
},
},
executor: this.verifyWork.bind(this),
},
{
definition: {
name: "pica_works_attest",
scope: "write:catalog",
audiences: ["stdio", "http"],
tier: "write",
description: "Put a human-made stamp on a work — a signed statement that a " +
"person created it. Use when the user asks: 'attest that I wrote " +
"this', 'stamp this work as human-made', 'sign off on " +
"provenance', 'mark this human-created for sync'. This is the " +
"human counterpart to ai_disclosure: it records who signed, when, " +
"how, and an optional signature hash (the raw signature itself is " +
"never stored). Sync libraries and provenance certificates use it " +
"to prove human authorship. To change or undo one, use the " +
"provenance revocation tools. Only a signed-in person can make " +
"this statement: an API key, connected app or agent is refused " +
"with ATTESTATION_REQUIRES_HUMAN_SESSION — do not retry; tell the " +
"user to use 'stamp as human-made' at /inspect/works/<id>#human-made. " +
"After stamping, read it back with " +
"pica_works_inspect (sections: ['provenance']). → then: " +
"pica_works_inspect (verify it persisted), " +
"pica_provenance_certificate (generate a PDF)",
workflows: ["work-required"],
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Work ID to attest.",
},
method: {
type: "string",
enum: [
"self_declaration",
"collaborator_verified",
"third_party",
],
description: "Attestation method. Default: 'self_declaration'. 'collaborator_verified' signals a co-writer has confirmed authorship; 'third_party' signals an external witness (manager, lawyer, sync agent).",
},
supporting_evidence: {
type: "object",
description: "Optional flags that strengthen the attestation. session_files / stems_available / creation_timeline are booleans; witness_collaborators is an array of person UUIDs. Evidence strength is computed automatically (basic / moderate / strong) and surfaced in certificate PDFs.",
properties: {
session_files: { type: "boolean" },
stems_available: { type: "boolean" },
creation_timeline: { type: "boolean" },
witness_collaborators: {
type: "array",
items: { type: "string" },
},
},
},
signature: {
type: "string",
description: "Optional raw signature payload (e.g. typed name + date). Hashed to signature_hash with sha256 before storage — the raw value is NEVER persisted. Omit when stamping without a signed-name record.",
},
},
required: ["id"],
},
nextSteps: [
{
tool: "pica_works_inspect",
reason: "Read the provenance section back with sections:['provenance'] to confirm the new attestation entry landed.",
when: "on_success",
},
{
tool: "pica_provenance_certificate",
reason: "Generate a PDF certificate from the attestation — sync libraries and ML training-data buyers consume this.",
when: "on_success",
},
],
},
executor: this.attestWork.bind(this),
},
{
// ADR-264 WS2 — verify-before-assert for registration gaps.
definition: {
name: "pica_works_verify_registrations",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Use when the user asks: 'verify my registration status with the MLC', 'double-check these " +
"registration gaps', 'confirm these works are unregistered'. Verify the registration status of up to 25 works. " +
"mode 'check' looks each work up at the MLC (by ISWC, else title + writer IPI, else title + " +
"primary-artist name) and records what the lookup PROVED: a found registration is written as a " +
"positive society fact; a clean not-found is recorded as a verified-absent assertion with a " +
"lookup receipt. Lookup errors and works with no usable identifiers write NOTHING (results " +
"'lookup_failed' / 'insufficient_identifiers' — an error or blind search is not evidence). " +
"mode 'confirm_unregistered' records the user's own confirmation instead (no lookup; " +
"society_code optional — omitted means 'not registered with any society'). " +
"Idempotent — re-running refreshes checked_at. " +
"This is a registration check, not a human sign-off — a person's own statement that a human made the work is pica_works_attest (signed-in only).",
workflows: ["work-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: {
work_ids: {
type: "array",
items: { type: "string" },
description: "Work UUIDs to verify (1-25 per call).",
},
mode: {
type: "string",
enum: ["check", "confirm_unregistered"],
description: "'check' = run the MLC lookup; 'confirm_unregistered' = record the user's own assertion without a lookup.",
},
society_code: {
type: "string",
description: "confirm_unregistered only — scope the confirmation to one society (e.g. 'PRS'). Omit to record 'not registered with any society'.",
},
},
required: ["work_ids", "mode"],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
nextSteps: [
{
tool: "pica_dashboard_attention",
reason: "Re-read the works_without_registrations rows — their evidence states now reflect what the verification proved.",
when: "on_success",
},
],
},
executor: this.verifyRegistrations.bind(this),
},
{
definition: {
name: "pica_works_bulk_delete",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "destructive",
description: "Delete multiple works at once. Each work follows the same rule as " +
"pica_works_delete: unlinked from its releases and deleted, or " +
"archived if it has registry identifiers or dependent records.",
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: {
ids: {
type: "array",
description: "Array of work IDs to delete",
items: {
type: "string",
},
},
dry_run: {
type: "boolean",
description: "When true, returns { dry_run: true, would_affect, target_count } without deleting any work. Preview before committing — bulk deletes cascade to credits and agreement links and cannot be undone for works without verified identifiers.",
},
},
required: ["ids"],
},
previewMode: "dry_run",
},
executor: this.bulkDeleteWorks.bind(this),
},
{
definition: {
name: "pica_works_bulk_move_to_performances",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "destructive",
description: "Reclassify multiple works as live performances and DELETE the source work rows. " +
"Use when the catalog has works that are actually performances of existing repertoire " +
"(covers, live renditions). Each work becomes a new live-performance record with " +
"title/artist mapped over; the source work is then deleted. Per-row results " +
"are returned — partial success is possible. " +
"Prefer pica_works_bulk_delete when the works should simply go, with no performance record created.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Destructive reclassification; invoked by exact name after explicit user confirmation in a specialist flow.",
inputSchema: {
type: "object",
properties: {
ids: {
type: "array",
description: "Array of work IDs to reclassify as live performances",
items: { type: "string" },
},
dry_run: {
type: "boolean",
description: "When true, returns { dry_run: true, would_affect, target_count } without creating any performance row or deleting any work. Strongly recommended — this operation deletes work rows after creating the performance counterparts.",
},
},
required: ["ids"],
},
previewMode: "dry_run",
},
executor: this.bulkMoveToPerformances.bind(this),
},
];
}
/**
* Query works — unified search, list, and health filter
*/
async queryWorks(args) {
// `verified` used to filter rows on `work.is_verified`, a column no
// applied migration adds to `works`, so it matched nothing whichever way
// it was set. Refuse
// it by name rather than ignore it: an agent that asked for "verified
// works" would otherwise read the whole catalogue as the answer.
if (args.verified !== undefined) {
return typedError("NO_WORK_VERIFIED_FLAG", "pica_works_query has no 'verified' filter: PICA does not keep a single 'verified' flag on a work. " +
"To find works missing what a registration needs, use health_filter: 'low_completeness' or " +
"completeness_score_lt; to review one work's confirmed and unconfirmed records, use pica_works_inspect.");
}
const limit = args.limit || 50;
const offset = args.offset || 0;
// Health filter routes to health/completeness APIs.
//
// ⚠️ Both branches used to call the bare SDK method with no arguments,
// so `limit`/`offset` were ignored (the routes' own default of 20 won)
// and `total` was the length of that page — an agent asking for 100
// low-completeness works got 20 and was told there were 20 (ops issue
// 314325eb, 2026-09-25). Both now thread limit/offset through and report
// only the totals the server can actually vouch for.
if (args.health_filter === "needs_attention") {
// Each candidate is a full cross-org chain assembly, so the route caps
// a page at 50 candidates; `offset` pages candidates, not results.
const page = await this.pica.health.getWorksHealthPage({
limit: Math.min(limit, WORKS_HEALTH_MAX_PAGE),
offset,
unreconciledOnly: true,
});
const works = page.data;
const meta = page.meta;
// No meta (an older server) means nothing is known about the rest of
// the set: treat it as "more may remain" rather than "done".
const hasMore = meta ? meta.has_more : works.length > 0;
// A total is only claimed when THIS call, starting at offset 0, checked
// every candidate and the server said so (meta.has_more false).
// Otherwise the page length is not passed off as the total.
const wholeSetScanned = offset === 0 && meta !== undefined && !hasMore;
return formatStructuredList(works, "work", {
filter: "needs_attention",
total: wholeSetScanned ? works.length : null,
...(wholeSetScanned
? {}
: {
total_note: hasMore
? "no total yet — the number needing attention is known only after every candidate work is checked; keep paging with next_offset and count the rows across pages"
: "this was the last page — the number needing attention is the rows counted across the pages you fetched",
}),
limit: meta?.limit ?? Math.min(limit, WORKS_HEALTH_MAX_PAGE),
offset,
scanned: meta?.scanned ?? null,
candidates_total: meta?.candidates_total ?? null,
hasMore,
next_offset: meta?.next_offset ?? null,
});
}
if (args.health_filter === "low_completeness") {
const page = await this.pica.health.getLowScoreWorksPage({
limit: Math.min(limit, LOW_COMPLETENESS_MAX_PAGE),
offset,
});
const works = page.data;
const meta = page.meta;
const appliedLimit = meta?.limit ?? Math.min(limit, LOW_COMPLETENESS_MAX_PAGE);
const total = meta?.total ?? null;
const hasMore = meta?.has_more ??
(total === null ? works.length >= appliedLimit : false);
return formatStructuredList(works, "work", {
filter: "low_completeness",
threshold: meta?.threshold,
total,
limit: appliedLimit,
offset,
hasMore,
next_offset: hasMore ? offset + appliedLimit : null,
});
}
// ADR-244 PR (c) §3.8 / §3.9 — queryable + sortable signals threaded
// through the SDK. The index does the work on the cached column
// `works.completeness_score`; has_statement_data is derived per row
// from work_registrations and applied as a post-flatten filter.
const hasStatementData = args.has_statement_data;
const completenessScoreLt = args.completeness_score_lt;
const completenessScoreGt = args.completeness_score_gt;
// ADR-270 Phase (a) — missing field count range filters.
const missingFieldCountGt = args.missing_field_count_gt;
const missingFieldCountLt = args.missing_field_count_lt;
// ADR-270 Phase (b) — streaming activity score range filters.
const streamingActivityGt = args.streaming_activity_gt;
const streamingActivityLt = args.streaming_activity_lt;
const sort = args.sort;
// Society-agnostic registration filter — "works registered with ASCAP"
// answered off the society side table, no society-named column referenced.
const societyCode = args.society_code;
// Always route through .search() when ADR-244 PR (c) filters/sort are
// present (and on any text-query call) so the SDK applies them.
const result = await this.pica.works.search({
query: args.query,
limit,
offset,
has_statement_data: hasStatementData,
completeness_score_lt: completenessScoreLt,
completeness_score_gt: completenessScoreGt,
missing_field_count_gt: missingFieldCountGt,
missing_field_count_lt: missingFieldCountLt,
streaming_activity_gt: streamingActivityGt,
streaming_activity_lt: streamingActivityLt,
sort,
society_code: societyCode,
work_type: args.work_type,
work_status: args.work_status,
genre: args.genre,
mood: args.mood,
tempo_min: args.tempo_min,
tempo_max: args.tempo_max,
primary_artist: args.primary_artist,
artist: args.artist,
label: args.label,
available_for_licensing: args.available_for_licensing,
release_format: args.release_format,
is_featured: args.is_featured,
published: args.published,
created_from: args.created_from,
created_to: args.created_to,
has_iswc: args.has_iswc,
has_lyrics: args.has_lyrics,
has_publisher: args.has_publisher,
});
let items = result.data || result;
if (!Array.isArray(items))
items = [];
// `light` projection — a full works row is ~1KB (completeness components,
// streaming sub-scores, AI disclosure, provenance, album-art URLs), so a
// 100-row page can blow the 100KB MCP response cap. When an agent only
// needs identity/registration fields (e.g. reconciling a PRO export against
// the catalogue), `light: true` returns just the scalars so a wide page
// fits comfortably. (Founder feedback a03bfd1b.) Build a separate array so
// the typed `items: Work[]` isn't reassigned to the projected shape.
const outItems = args.light
? items.map((w) => ({
id: w.id,
title: w.title,
primary_artist: w.primary_artist,
iswc: w.iswc ?? null,
tunecode: w.tunecode ?? null,
isrc: w.isrc ?? null,
work_status: w.work_status,
completeness_score: w.completeness_score,
missing_field_count: w.missing_field_count,
}))
: items;
return formatStructuredList(outItems, "work", {
query: args.query,
total: result.total || outItems.length,
limit,
hasMore: result.hasMore,
...(args.light ? { projection: "light" } : {}),
});
}
/**
* Inspect work — unified detail view with selectable sections
*/
/**
* Resolve an ISWC to a work id via the works search (which matches ISWC
* server-side — see applyWorksFilters). Takes the first EXACT (dash/dot
* stripped, case-insensitive) match. Throws EntityNotFoundError (→ NOT_FOUND)
* when nothing matches, so inspect surfaces a hard not-found (ADR-198).
*/
async resolveWorkIdByIswc(iswc) {
const norm = (v) => v.replace(/[-.\s]/g, "").toUpperCase();
const target = norm(iswc);
// Query with the NORMALIZED value: works.iswc is stored dash/dot-free and
// the works search matches iswc via a raw ILIKE substring (no stripping), so
// a formatted input (e.g. "T-034.524.680-1") would miss the stored
// "T0345246801" and false-NOT_FOUND. We still exact-match below.
const { data } = await this.pica.works.search({ query: target, limit: 50 });
const match = data.find((w) => w.iswc && norm(w.iswc) === target);
if (!match) {
throw new EntityNotFoundError(`No work found with ISWC ${iswc}. Use pica_search_all to locate it, or pica_works_query to browse.`);
}
return match.id;
}
/**
* pica_works_inspect in a CLIENT's catalogue (ADR-333 PR 1.3). Only the
* song's own record opens through a relationship (GET /api/admin/works/:id,
* in the safe shape — lib/auth/catalogue-routes.ts); every other section's
* route is refused there. So: no sections asked → the record, saying what
* is not available; a section asked that is not available → refused,
* naming it. Never the all-sections path, which fails each refused section
* soft and would answer "success" about a song it never read (D2 Gate 2,
* holdout run 38435148161).
*/
async inspectWorkInClientCatalogue(workId, sections) {
const unavailable = (sections ?? []).filter((s) => !CLIENT_CATALOGUE_WORK_SECTIONS.includes(s));
if (unavailable.length > 0) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "CATALOGUE_SECTION_NOT_AVAILABLE",
message: `In a client's catalogue only ${CLIENT_CATALOGUE_WORK_SECTIONS.join(", ")} can be read; not ${unavailable.join(", ")}.`,
available_sections: CLIENT_CATALOGUE_WORK_SECTIONS,
}, null, 2),
},
],
isError: true,
};
}
// Refused (out of scope, ended) → thrown, and answered as an error.
const work = await this.pica.works.get(workId);
return formatSuccess("Work details", {
work,
available_sections: CLIENT_CATALOGUE_WORK_SECTIONS,
note: "in a client's catalogue only the song's own record can be read through the relationship",
});
}
async inspectWork(args) {
// Accept work_id as an alias for id — write tools use work_id, so agents
// doing read-after-write naturally pass work_id here. Without this alias
// args.id would be undefined and credits.listForWork(undefined) would
// silently return [] (ADR-198 silent-empty footgun).
let workId = (args.id ?? args.work_id);
// Accept iswc as an entry point — resolve it to the work id (throws
// EntityNotFoundError → NOT_FOUND when no work carries that ISWC).
if (!workId && typeof args.iswc === "string" && args.iswc.trim()) {
workId = await this.resolveWorkIdByIswc(args.iswc.trim());
}
if (!workId) {
return {
content: [
{ type: "text", text: "id (or work_id or iswc) is required." },
],
isError: true,
};
}
const sections = args.sections;
if (currentCatalogue() !== undefined)
return this.inspectWorkInClientCatalogue(workId, sections);
const allSections = !sections || sections.length === 0;
// Sections kept OFF the default `allSections` surface but still callable
// via explicit `sections`:
// - `health` is a summary of THIS work's completeness (the same
// single-work read as `completeness`), so on the default surface it
// would only repeat that section. Until 2026-09-26 it read the
// ORG-WIDE works-health scan instead — ~25s, and about other works.
// - `score` (ADR-201 closeout, 2026-04-27) is the org-wide PICA score;
// it takes no workId.
// - `dossier` (2026-07-12) assembles the whole song (recordings,
// releases, credits, people, provenance, consent) — a heavy superset of
// the other sections, redundant on the default surface and worth its
// own explicit opt-in.
const OPT_IN_ONLY = new Set(["health", "score", "dossier"]);
const has = (s) => allSections ? !OPT_IN_ONLY.has(s) : sections.includes(s);
const result = {};
const hints = [];
// ADR-224 — section-level failures push to errors[] and the host
// log pipeline (via pushSectionError) regardless of flag state.
// The flag controls whether the public response advertises errors[]
// and whether failed sections set the field to null vs the legacy
// empty-fallback. Default OFF in Phase 1; flips to default-on at
// Phase 4 after the soak window catches any caller depending on
// the empty-array shape.
const useStructured = process.env.ADR_224_STRUCTURED_ERRORS === "1";
const errors = [];
const TOOL_NAME = "pica_works_inspect";
// Basic — always fetched (either via entityContext.getWorkFull or works.get).
// Wrapped in try/catch so a primary-entity fetch error degrades into a
// partial response with a sibling `*_error` field instead of throwing past
// the whole inspect call (mirrors the recordings_inspect pattern at
// mcp-server/src/tools/recordings.ts:450). 404 stays a hard error via
// EntityNotFoundError propagation so the agent sees an explicit not-found
// signal instead of a 200-with-empty-payload (ADR-198 doctrine).
if (allSections) {
try {
const full = await this.pica.entityContext.getWorkFull(workId);
const data = full;
result.work = data?.work || data;
result.credits = data?.credits;
result.recordings = data?.recordings;
result.agreements = data?.agreements;
}
catch (error) {
if (error instanceof EntityNotFoundError)
throw error;
// A 400 on the PRIMARY entity fetch is an invalid work id (upstream
// VALIDATION_ERROR — e.g. a non-UUID id), not a degraded section.
// Fail-softing it produced a success envelope with every section
// null and the 400 buried in work_error — no isError, no recovery
// hint (mcp-layered-discovery holdout red, 2026-07-18 cleanup).
// Propagate like the 404 above so formatError surfaces a proper
// error envelope + the pica_works_inspect recovery hint.
if (error instanceof ApiError && error.status === 400)
throw error;
if (!useStructured) {
result.work_error =
error instanceof Error ? error.message : "Failed to fetch work";
}
else {
result.work = null;
}
pushSectionError(errors, TOOL_NAME, "basic", error, workId);
}
}
else if (has("basic")) {
try {
const work = await this.pica.works.get(workId);
result.work = work;
// Enrich with ISRC prefix intelligence if work has ISRC
if (work?.isrc) {
try {
result.prefix_intel = await this.pica.recordings.prefixLookup(work.isrc);
}
catch {
/* best-effort */
}
}
}
catch (error) {
if (error instanceof EntityNotFoundError)
throw error;
// A 400 on the PRIMARY entity fetch is an invalid work id (upstream
// VALIDATION_ERROR — e.g. a non-UUID id), not a degraded section.
// Fail-softing it produced a success envelope with every section
// null and the 400 buried in work_error — no isError, no recovery
// hint (mcp-layered-discovery holdout red, 2026-07-18 cleanup).
// Propagate like the 404 above so formatError surfaces a proper
// error envelope + the pica_works_inspect recovery hint.
if (error instanceof ApiError && error.status === 400)
throw error;
if (!useStructured) {
result.work_error =
error instanceof Error ? error.message : "Failed to fetch work";
}
else {
result.work = null;
}
pushSectionError(errors, TOOL_NAME, "basic", error, workId);
}
}
// ADR-201 closeout (2026-04-27): the section sub-calls used to run
// sequentially. With ~8 calls each taking ~1s and one (the org-wide
// works-health scan `health` read until 2026-09-26)
// sometimes 25s+, the function would frequently exceed Vercel's 30s
// maxDuration on the default `allSections` path. Fan out everything
// in parallel — each branch still owns its own try/catch fallback so
// a single failing sub-call doesn't poison the whole inspect.
const sectionTasks = [];
// Targeted basic sections — only when caller listed them explicitly.
if (!allSections) {
if (has("credits") && !result.credits) {
sectionTasks.push((async () => {
try {
result.credits = await this.pica.credits.listForWork(workId);
}
catch (e) {
result.credits = useStructured ? null : [];
pushSectionError(errors, TOOL_NAME, "credits", e, workId);
}
})());
}
if (has("recordings") && !result.recordings) {
sectionTasks.push((async () => {
try {
result.recordings = await this.pica.recordings.getByWork(workId);
}
catch (e) {
result.recordings = useStructured ? null : [];
pushSectionError(errors, TOOL_NAME, "recordings", e, workId);
}
})());
}
if (has("agreements") && !result.agreements) {
sectionTasks.push((async () => {
try {
// This work's agreements — not the org's first page of them.
result.agreements = await this.pica.works.getAgreements(workId);
}
catch (e) {
result.agreements = useStructured ? null : [];
pushSectionError(errors, TOOL_NAME, "agreements", e, workId);
}
})());
}
}
// Quality sections — fan out in parallel. `health` and `completeness`
// share ONE read of this work's completeness route.
let completenessRead = null;
const readCompleteness = () => (completenessRead ??= this.pica.health.getWorkCompleteness(workId));
if (has("health")) {
sectionTasks.push((async () => {
try {
const c = await readCompleteness();
result.health = {
scope: "work",
work_id: workId,
score: c?.score ?? null,
missing_field_count: c?.missing_field_count ?? null,
missing_critical: c?.missingCritical ?? [],
suggestions: c?.suggestions ?? [],
};
}
catch (e) {
result.health = null;
pushSectionError(errors, TOOL_NAME, "health", e, workId);
}
})());
}
if (has("completeness")) {
sectionTasks.push((async () => {
try {
const completeness = await readCompleteness();
result.completeness = completeness;
hints.push(...mapGapsToHints(completeness?.gaps || completeness?.missing || []));
// ops_issue 1bc63a6d — only an instrumental/PA is attached. The
// candidates (unassigned files whose name matches the title) sit
// in completeness.audio.gap; they are for the user to confirm.
if (completeness?.audio?.checked === false) {
hints.push({
gap: "audio_check_unavailable",
suggestion: "PICA could not check this work's attached audio just now, so an 'only an instrumental is attached' gap may be missing from this answer.",
severity: "nice_to_have",
});
}
const vocalGap = completeness?.audio?.gap;
if (vocalGap && typeof vocalGap.label === "string") {
const ids = Array.isArray(vocalGap.candidates)
? vocalGap.candidates
.map((c) => c.audio_file_id)
.filter(Boolean)
: [];
hints.push({
gap: "only_instrumental_attached",
suggestion: ids.length > 0
? `${vocalGap.label} (${ids.join(", ")}). If the user confirms one is the vocal master, it can be attached with pica_audio_assign.`
: `${vocalGap.label}.`,
severity: "important",
});
}
}
catch (e) {
result.completeness = null;
pushSectionError(errors, TOOL_NAME, "completeness", e, workId);
}
})());
}
if (has("registration")) {
sectionTasks.push((async () => {
try {
result.registration =
await this.pica.registration.getWorkCascadeStatus(workId);
}
catch (e) {
result.registration = null;
pushSectionError(errors, TOOL_NAME, "registration", e, workId);
}
})());
}
if (has("score")) {
sectionTasks.push((async () => {
try {
result.score = await this.pica.picaScore.get();
}
catch (e) {
result.score = null;
pushSectionError(errors, TOOL_NAME, "score", e, workId);
}
})());
}
if (has("provenance")) {
sectionTasks.push((async () => {
try {
result.provenance =
await this.pica.analytics.provenanceWork(workId);
}
catch (e) {
result.provenance = null;
pushSectionError(errors, TOOL_NAME, "provenance", e, workId);
}
})());
}
// ADR-277 WS2 — per-field provenance: each non-null field's stakes tier +
// attestation / corroboration flags. Included on the default all-sections
// surface (like `provenance`), and selectable on its own via `sections`. A
// derive failure degrades the section to null, never the whole inspect call.
if (has("field_provenance")) {
sectionTasks.push((async () => {
try {
result.field_provenance =
await this.pica.health.fieldProvenanceWork(workId);
}
catch (e) {
result.field_provenance = null;
pushSectionError(errors, TOOL_NAME, "field_provenance", e, workId);
}
})());
}
if (has("enrichment")) {
sectionTasks.push((async () => {
try {
result.enrichment =
await this.pica.enrichment.getWorkEnrichmentStatus(workId);
}
catch (e) {
result.enrichment = null;
pushSectionError(errors, TOOL_NAME, "enrichment", e, workId);
}
})());
}
// ADR-156 op #3 Session B Task 5 — surface linked physical assets
// so the inspect tool is discoverable from the work side.
if (has("production_assets")) {
sectionTasks.push((async () => {
try {
result.production_assets =
await this.pica.works.listProductionAssets(workId);
}
catch (e) {
result.production_assets = useStructured ? null : [];
pushSectionError(errors, TOOL_NAME, "production_assets", e, workId);
}
})());
}
// ADR-289 Wave B1 item 1 — licensing/compliance fields, scoped separately
// from the audit-trail `provenance` section. These fields already live
// flat on the work object (work_licensing satellite, flattened by
// flattenWorkSatellites server-side) — reuse `result.work` if a "basic"
// fetch already ran (allSections, or "basic" explicitly requested
// alongside "licensing"), otherwise fetch fresh.
if (has("licensing")) {
sectionTasks.push((async () => {
try {
const work = result.work ??
(await this.pica.works.get(workId));
result.licensing = {
is_licensable: work?.is_licensable ?? null,
available_for_licensing: work?.available_for_licensing ?? null,
licensing_terms: work?.licensing_terms ?? null,
licensing_price_usd: work?.licensing_price_usd ?? null,
licensing_notes: work?.licensing_notes ?? null,
ai_disclosure: work?.ai_disclosure ?? null,
training_rights: work?.training_rights ?? null,
provenance_attestation: work?.provenance_attestation ?? null,
};
}
catch (e) {
result.licensing = null;
pushSectionError(errors, TOOL_NAME, "licensing", e, workId);
}
})());
}
// ADR-173 Decision 2 — every release this work appears on, with
// its track position. Distinct from the `recordings` section,
// which lists recordings regardless of release context.
if (has("releases")) {
sectionTasks.push((async () => {
try {
result.releases = await this.pica.works.listReleases(workId);
}
catch (e) {
result.releases = useStructured ? null : [];
pushSectionError(errors, TOOL_NAME, "releases", e, workId);
}
})());
}
// Song dossier (2026-07-12, spec 2026-07-11 §7) — the full one-call
// assembly of everything PICA holds about the song. Opt-in only (in
// OPT_IN_ONLY above), so this branch only runs when the caller lists
// "dossier" explicitly. The whole assembly degrades to null on failure —
// never poisons the rest of the inspect. The PDF comes from
// pica_export_song_dossiers.
if (has("dossier")) {
sectionTasks.push((async () => {
try {
result.dossier = await this.pica.works.dossier(workId);
}
catch (e) {
result.dossier = null;
pushSectionError(errors, TOOL_NAME, "dossier", e, workId);
}
})());
}
await Promise.all(sectionTasks);
// Compact text summary for the dossier section — the full payload rides
// structuredContent.data.dossier; this one-liner orients the agent
// without making it parse the JSON. Appended to the tool message below.
const dossierSummary = summariseDossier(result.dossier);
// ADR-193 Surface 2 — proactive peer-MCP hints when the flag is on
// and a real gap exists. Additive on top of the CompletionHint path.
const gap_hints = computeWorkGapHints(result);
const withHints = gap_hints.length > 0 ? { ...result, gap_hints } : result;
// ADR-224 — append errors[] to the public response only when the
// structured-errors flag is on. In legacy mode, section failures are
// still pushed to the errors[] array (so stderr observability fires
// via pushSectionError) but the array is dropped from the response
// shape so the breaking change ([] → null on failed sections) doesn't
// ship until Phase 4's flip.
const enriched = useStructured ? { ...withHints, errors } : withHints;
return formatSuccess(`Work details${sections ? ` (sections: ${sections.join(", ")})` : ""}` +
(dossierSummary ? `\n\n${dossierSummary}` : ""), enriched, hints);
}
/**
* Create work
*/
async createWork(args) {
const work = await this.pica.works.create(args);
// Generate completion hints for the newly created work
const hints = [];
if (work?.id) {
try {
const completeness = await this.pica.health.getWorkCompleteness(work.id);
hints.push(...mapGapsToHints(completeness?.gaps || completeness?.missing || []));
}
catch {
// Best-effort — don't fail the create
}
// Hint about AI disclosure if not set
if (!args.ai_disclosure) {
hints.push({
gap: "no_ai_disclosure",
suggestion: "Set AI disclosure — is this work human-made, AI-assisted, or AI-generated? (pica_works_update with ai_disclosure: none/assisted/generated)",
severity: "important",
});
}
}
// ADR-158 Plan C Phase 2 — append custody-claim hints so every new work
// prompts the rights conversation by default.
hints.push(...buildCustodyHints(work));
// ADR-280 advisory version-as-work guard — detect if the new title looks
// like a version of an existing work and suggest the correct modelling.
// BEST-EFFORT: any error → proceed with normal success, no warning.
// The guard runs AFTER the work is created and never blocks or rolls back.
//
// FIX (2026-06-27): replaced works.list({limit:500}) with a stem-targeted
// works.search({query:stem, limit:50}). The /admin/works route caps list at
// 100 rows, so a 438-work catalog silently skips the parent and the guard
// never fires. A title-stem search returns a small, relevant set regardless
// of catalog size, and includes the parent even on large orgs.
let versionWarning;
if (work?.id) {
try {
const parsed = parseVersionTitle(String(args.title ?? ""));
if (parsed) {
const searchRes = await this.pica.works.search({
query: parsed.stem,
limit: 50,
});
const rawWorks = Array.isArray(searchRes)
? searchRes
: (searchRes?.data ?? []);
const existing = rawWorks
.filter((w) => w.id !== work.id)
.map((w) => ({
id: String(w.id),
title: String(w.title ?? ""),
iswc: (w.iswc ?? null),
artistId: null, // SDK search() returns primary_artist (name), not ID
}));
const match = detectVersionOfExistingWork({
title: String(args.title ?? ""),
artistId: args.primary_artist_id ?? null,
}, existing);
if (match)
versionWarning = buildVersionWarning(match, String(work.id));
}
}
catch {
// Advisory only — never fail a create over a warning
}
}
// Registration integrity floor (spec 2026-07-09, Task 9) — assess the
// just-created work and attach both the rendered hints and the raw
// gap block. Best-effort: never blocks or fails the create.
// No dedup guard needed vs the getWorkCompleteness hints above (unlike
// import.ts): the completeness route returns WorkCompletenessResult
// (breakdown / missingCritical / suggestions — it has no `gaps` or
// `missing` keys), so `mapGapsToHints(completeness?.gaps ||
// completeness?.missing || [])` always maps [] and can never emit a
// writer/credits hint that would duplicate WORK_NO_WRITER.
const pressure = await buildIntegrityPressure(this.pica, "work", work.id);
hints.push(...pressure.hints);
// Spreadsheet-moment auto-twin (2026-07-10) — the SDK merges `twin`/
// `twin_error` onto the create() response (see WorksResource.create).
// Surface a completion hint when a twin recording was actually minted
// so the agent knows a second grain exists and how to opt out next time.
if (work.twin) {
hints.push({
gap: "twin_recording_created",
suggestion: `also created the linked recording ${work.twin.id} — pass create_twin: false to skip`,
severity: "nice_to_have",
});
}
const result = formatSuccess("Work created successfully", { ...work, integrity: pressure.integrity }, hints);
if (versionWarning && result.structuredContent) {
Object.assign(result.structuredContent, versionWarning);
}
return result;
}
/**
* Update work
*/
async updateWork(args) {
const { verbose, rest } = takeVerbose(args);
const { id, ...updates } = rest;
// In a client's catalogue the integrity reads are not opened through the
// relationship (lib/auth/catalogue-routes.ts): skip them rather than have
// each one refused.
const inClientCatalogue = currentCatalogue() !== undefined;
// Compact receipt (ops 61ce1dcc): read the gaps open BEFORE the write so
// the answer can name only the ones this write opened. Skipped when the
// caller asked for the full record, which repeats every gap anyway.
const before = verbose || inClientCatalogue
? null
: await buildIntegrityPressure(this.pica, "work", id);
const outcome = readChangeOutcome(await this.pica.works.update(id, updates));
if (outcome.kind === "proposed")
return formatProposedChange("work", id, Object.keys(updates), outcome);
const work = outcome.record;
if (inClientCatalogue) {
const { changed, unconfirmed } = changedFields(updates, work);
return formatSuccess("Work updated successfully", {
id: work?.id ?? id,
changed,
...(unconfirmed.length > 0 ? { unconfirmed } : {}),
});
}
// Registration integrity floor (Task 9) — same pressure rendering as
// createWork, so an update that clears (or introduces) a gap keeps the
// agent informed without a separate inspect round-trip.
const pressure = await buildIntegrityPressure(this.pica, "work", work.id);
if (verbose) {
return formatSuccess("Work updated successfully", { ...work, integrity: pressure.integrity }, pressure.hints);
}
const openBefore = before?.integrity
? new Set(before.integrity.gaps.map((g) => g.code))
: null;
const { changed, unconfirmed } = changedFields(updates, work);
return formatSuccess("Work updated successfully", {
id: work?.id ?? id,
changed,
...(unconfirmed.length > 0 ? { unconfirmed } : {}),
integrity: slimIntegrity(pressure.integrity, openBefore),
receipt: RECEIPT_NOTE,
}, newHintsOnly(before?.integrity ? before.hints : null, pressure.hints));
}
/**
* Delete work
*/
async deleteWork(args) {
const outcome = await this.pica.works.delete(args.id);
return formatDeletion("work", args.id, outcome);
}
/**
* pica_works_verify — writes nothing, and says so.
*
* It used to PATCH `is_verified: true` onto the work. `works` has no such
* column (no applied migration adds one), so the route's allowed-field check
* refused it on every call — its only two calls ever both failed (DonnySavv,
* feedback 86e26f75, 2026-09-28). PICA keeps no work-level "metadata
* verified" flag, and inventing one would record an agreement nobody made.
* What a person CAN confirm lives on specific records, each refused to
* agents and each done by the person on /inspect — this names them.
*/
async verifyWork(args) {
const workId = typeof args.id === "string" ? args.id.trim() : "";
if (!workId) {
return typedError("VALIDATION_ERROR", "id is required.");
}
return typedError("NO_WORK_VERIFIED_FLAG", "PICA does not keep a single 'verified' flag on a work, so nothing was written. " +
"What a person can confirm, each while signed in to PICA (an assistant cannot do these for them): " +
`(1) the human-made stamp — "stamp as human-made" at /inspect/works/${workId}#human-made; ` +
`(2) their own writer credit — confirm it in the credits table on /inspect/works/${workId}; ` +
'(3) their own master ownership share — "confirm my share" under ownership splits at ' +
"/inspect/recordings/<recording_id>#ownership-splits. " +
"To review what is missing or unconfirmed on the work, use pica_works_inspect.");
}
/**
* Bulk delete works
*/
async bulkDeleteWorks(args) {
if (args.dry_run === true) {
const ids = Array.isArray(args.ids) ? args.ids : [];
return formatSuccess(`Dry run — would delete ${ids.length} work(s)`, {
dry_run: true,
would_affect: ids,
target_count: ids.length,
});
}
await this.pica.works.bulkDelete(args.ids);
return formatSuccess(`${args.ids.length} works deleted successfully`);
}
/**
* Bulk reclassify works as live performances + delete source work rows
*/
async bulkMoveToPerformances(args) {
if (args.dry_run === true) {
const ids = Array.isArray(args.ids) ? args.ids : [];
return formatSuccess(`Dry run — would reclassify ${ids.length} work(s) as live performances and delete the source rows`, {
dry_run: true,
would_affect: ids,
target_count: ids.length,
});
}
const result = await this.pica.works.bulkMoveToPerformances(args.ids);
return formatSuccess(`Reclassified ${args.ids.length} work(s) to performances`, result);
}
/**
* Stamp a human-creation provenance attestation. Wraps the SDK call;
* the route handles authUserId → user_profiles.id resolution and the
* signature hashing.
*/
async attestWork(args) {
const workId = args.id;
if (!workId) {
return {
content: [{ type: "text", text: "id is required." }],
isError: true,
};
}
try {
const result = await this.pica.works.attest(workId, {
method: args.method,
supporting_evidence: args.supporting_evidence,
signature: args.signature,
});
const att = result.attestation;
const methodSummary = att?.attestation_method ?? "self_declaration";
const sigSummary = att?.signature_hash
? ` with signature ${att.signature_hash.slice(0, 12)}…`
: "";
return formatSuccess(`Provenance attestation stamped (${methodSummary})${sigSummary}`, result);
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
content: [
{
type: "text",
text: `Failed to stamp provenance attestation: ${errorMessage}`,
},
],
isError: true,
};
}
}
// ADR-264 WS2 — verify-before-assert for registration gaps.
async verifyRegistrations(args) {
const result = await this.pica.works.verifyRegistrations({
work_ids: args.work_ids,
mode: args.mode,
...(args.society_code ? { society_code: args.society_code } : {}),
});
const s = (result?.summary ?? {});
const parts = [];
if (s.found_registered)
parts.push(`${s.found_registered} found registered (positive fact written)`);
if (s.verified_absent)
parts.push(`${s.verified_absent} verified absent`);
if (s.confirmed)
parts.push(`${s.confirmed} confirmed unregistered`);
if (s.already_registered)
parts.push(`${s.already_registered} already registered`);
if (s.lookup_failed)
parts.push(`${s.lookup_failed} lookup failed (no assertion written)`);
if (s.insufficient_identifiers)
parts.push(`${s.insufficient_identifiers} lacked identifiers to search by`);
if (s.not_found)
parts.push(`${s.not_found} not found in your catalog`);
// ADR-267 Rule 3 — when a check leaves works unverified, frame the
// confirm_unregistered follow-up as a calibrated question instead of
// leaving the agent to invent one. confirm mode IS the resolution, so
// it never carries a decision.
const unresolved = args.mode === "check"
? (s.lookup_failed ?? 0) + (s.insufficient_identifiers ?? 0)
: 0;
const decision = unresolved > 0
? buildDecision({
question: `we couldn't verify ${unresolved} of these with the MLC — how would you like to leave them?`,
options: [
{
label: "leave them as open questions",
consequence: "nothing is recorded — you can run the check again another time",
},
{
label: "record your own confirmation",
consequence: "they're marked unregistered on your word rather than a lookup — run this verification again in confirm mode",
},
],
safe_default: "leave them as open questions",
})
: null;
return formatSuccess(parts.length > 0
? `Registration verification: ${parts.join(", ")}.`
: "Registration verification ran — no works processed.", decision ? { ...result, decision } : result);
}
}
//# sourceMappingURL=works.js.map