@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
1,363 lines • 81.3 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { formatProposedChange, readChangeOutcome, } from "./relationship-change.js";
import { formatAsText, formatSuccess } from "@withpica/mcp-utils";
import { computeProposalListGapHints } from "./gap-hints.js";
export class EnrichmentTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
return [
// ============================================================
// ADR-179: outcome-shaped resolver (replaces 5 per-source tools)
// ============================================================
{
definition: {
name: "pica_resolve_recording",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Resolve a recording against every eligible external source in one call — Spotify, YouTube, MusicBrainz, Discogs. " +
"First recording-side enrichment tool in the MCP. Returns a structured receipt: " +
"`applied[]` (identifier-matched Tier A writes), `proposals[]` (Tier B fuzzy matches — reserved for future cascade rules), " +
"`errors[]` (per-source failures with retry hints), " +
"`overwrote[]` (a higher-authority source replaced a lower-ranked value — old/new source shown), and " +
"`skipped_lower_authority[]` (a source was NOT allowed to write because the existing value out-ranked it, equalled it, or was a human/unprovenanced value). " +
"Partial failure is not an error — isError is set only when EVERY eligible source errored. " +
"Writes are governed by a field-level authority gate: higher-authority sources win, human-entered and pre-provenance values are locked. " +
"Spotify adapter fetches Spotify track metadata (triggered by the canonical spotify_track_id on the recording) " +
"and fills preview_url, duration_ms, isrc, and the URL set. " +
"YouTube adapter fetches video metadata (triggered by youtube_video_id) and classifies version_type when it is empty. " +
"MusicBrainz adapter fills musicbrainz_recording_id + duration_ms from an ISRC lookup; Discogs is release-grain only. " +
"Prefer pica_resolve_work when the target is the song rather than one recorded take. " +
"→ then: pica_recordings_inspect (see what changed)",
workflows: ["enrichment-resolve-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: {
recording_id: {
type: "string",
description: "The recording ID to resolve",
},
sources: {
type: "array",
items: {
type: "string",
enum: ["spotify", "youtube", "musicbrainz", "discogs"],
},
description: "Optional whitelist of sources to run. Omit to fan out to every eligible source.",
},
include_fuzzy: {
type: "boolean",
description: "Default true. Set false to skip Tier B fuzzy-match rules.",
},
force: {
type: "boolean",
description: "Default false. Force re-enrichment. Safe by construction — the authority gate never clobbers a human / higher-authority / unprovenanced value, so a forced re-run only fills gaps and upgrades lower-ranked automated values.",
},
},
required: ["recording_id"],
},
},
executor: this.resolveRecording.bind(this),
},
{
definition: {
name: "pica_resolve_release",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Resolve a release against Spotify (v1: Spotify-only; `sources[]` reserved for future sources). " +
"Writes release-grain fields — UPC, release date, artwork, title, label — where Spotify is authoritative. " +
"Returns a structured receipt: " +
"`applied[]` (fields written), " +
"`errors[]` (per-source failures with retry hints), " +
"`overwrote[]` (a higher-authority source replaced a lower-ranked value — old/new source shown), and " +
"`skipped_lower_authority[]` (a source was NOT allowed to write because the existing value out-ranked it, equalled it, or was a human/unprovenanced value). " +
"Partial failure is not an error — isError is set only when EVERY eligible source errored. " +
"Writes are governed by a field-level authority gate: higher-authority sources win, human-entered and pre-provenance values are locked. " +
"A release resolve ALWAYS re-checks Spotify and gap-fills empty fields via the authority gate; `force` is accepted for parity but has no distinct effect here. " +
"For one track rather than the whole release, use pica_resolve_recording. " +
"→ then: pica_releases_inspect (see what changed)",
workflows: ["enrichment-resolve-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: {
release_id: {
type: "string",
description: "The release ID to resolve",
},
sources: {
type: "array",
items: {
type: "string",
enum: ["spotify"],
},
description: "Optional whitelist of sources to run. v1 supports 'spotify' only. Omit to run every eligible source.",
},
force: {
type: "boolean",
description: "Accepted for parity with other resolve tools; has no distinct effect for releases (no idempotence stamps exist for releases — every call re-checks Spotify and gap-fills via the authority gate).",
},
},
required: ["release_id"],
},
},
executor: this.resolveRelease.bind(this),
},
{
definition: {
name: "pica_resolve_person",
scope: "write:people",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Use when the user asks: 'find more information about this person', 'enrich this person', 'look up this artist's identifiers', 'pull their details from external sources'. " +
"Resolve a person against every eligible identity-graph source in one call — ISNI, MusicBrainz, Wikidata (downstream of both). " +
"Returns a structured receipt: " +
"`applied[]` (identifier-matched Tier A writes — wikidata_id, awards, biography), " +
"`proposals[]` (fuzzy Tier B matches filed to the review queue), " +
"`errors[]` (per-source failures with retry hints). Partial failure is not an error — isError is set only when EVERY " +
"eligible source errored. " +
"Sources list is optional; omit to fan out to every source whose trigger identifiers are present on the person. " +
"Narrow to a single source with e.g. `sources: ['isni']` to call just one. " +
"Prefer pica_resolve_work when enriching a song rather than a person. " +
"→ then: pica_enrichment_proposals_list (review fuzzy matches), pica_people_inspect (see what changed), pica_enrichment_proposal_apply (accept a proposal)",
workflows: ["enrichment-resolve-required"],
// Flipped specialist → creator-entry 2026-07-08: the founder asked
// for exactly this capability in natural language and could not
// find it — the telemetry ADR-226 Phase 4 was waiting for.
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
person_id: {
type: "string",
description: "The person ID to resolve",
},
sources: {
type: "array",
items: {
type: "string",
enum: ["isni", "musicbrainz"],
},
description: "Optional whitelist of sources to run. Omit to fan out to every eligible source.",
},
include_fuzzy: {
type: "boolean",
description: "Default true. Set false to skip Tier B fuzzy-match rules (no review proposals queued).",
},
},
required: ["person_id"],
},
},
executor: this.resolvePerson.bind(this),
},
{
definition: {
name: "pica_resolve_work",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Use when the user asks: 'find more information about this song', 'enrich this song', 'look this song up in the MLC', 'pull this track's metadata from external sources'. " +
"Resolve a work against every eligible external source in one call — MLC, Spotify, YouTube, MusicBrainz, Discogs. " +
"Returns a structured receipt: " +
"`applied[]` (identifier-matched Tier A writes), `proposals[]` (fuzzy Tier B matches filed to the review queue), " +
"`errors[]` (per-source failures with retry hints), " +
"`overwrote[]` (a higher-authority source replaced a lower-ranked value — old/new source shown), and " +
"`skipped_lower_authority[]` (a source was NOT allowed to write because the existing value out-ranked it, equalled it, or was a human/unprovenanced value). " +
"Partial failure is not an error — isError is set only when EVERY eligible source errored. " +
"Writes are governed by a field-level authority gate: higher-authority sources win, human-entered and pre-provenance values are locked. " +
"Sources list is optional; omit to fan out to every source whose trigger identifiers are present on the work. " +
"Narrow to a single source with e.g. `sources: ['spotify']` to call just one. " +
"Prefer pica_resolve_recording when the target is a specific recorded take rather than the song. " +
"Set `force: true` to re-run past the idempotence stamps (non-destructive — the gate protects human / higher-authority / unprovenanced values). " +
"→ then: pica_enrichment_proposals_list (review fuzzy matches), pica_works_inspect (see what changed), pica_enrichment_proposal_apply (accept a proposal)",
workflows: ["enrichment-resolve-required"],
// Flipped specialist → creator-entry 2026-07-08 (same telemetry
// trigger as pica_resolve_person above).
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
work_id: {
type: "string",
description: "The work ID to resolve",
},
sources: {
type: "array",
items: {
type: "string",
enum: ["mlc", "spotify", "youtube", "musicbrainz", "discogs"],
},
description: "Optional whitelist of sources to run. Omit to fan out to every eligible source.",
},
include_fuzzy: {
type: "boolean",
description: "Default true. Set false to skip Tier B fuzzy-match rules (no review proposals queued).",
},
force: {
type: "boolean",
description: "Default false. Force re-enrichment — clears the per-source idempotence stamps so rules re-fire. Safe by construction: the authority gate never clobbers a human / higher-authority / unprovenanced value.",
},
},
required: ["work_id"],
},
},
executor: this.resolveWork.bind(this),
},
{
definition: {
name: "pica_enrichment_candidates",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Find works and people that are candidates for enrichment (missing external identifiers or metadata). " +
"Prefer pica_enrichment_proposals_list when reviewing findings already queued for review. " +
"→ then: pica_resolve_work (fan out across every eligible source), pica_resolve_person (same for people)",
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: {},
},
},
executor: this.enrichmentCandidates.bind(this),
},
{
definition: {
name: "pica_run_work_cascade",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Re-evaluate a work against every cascade rule and fire those whose preconditions are satisfied. " +
"Use after a tool or direct edit that bypassed the cascade, or to retry enrichment after adding a missing identifier. " +
"Idempotent: rules whose output is already present skip. Runs fire-and-forget; check results via pica_works_inspect. " +
"→ then: pica_works_inspect (see what changed), pica_cascade_health (spot-check failures)",
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: "The work ID to re-evaluate",
},
},
required: ["work_id"],
},
},
executor: this.runWorkCascade.bind(this),
},
{
definition: {
name: "pica_run_person_cascade",
// The route requires write:catalog; proxy.ts floors writes under
// /api/admin/people/* at write:people. Pinned by
// lib/auth/__tests__/mcp-tool-scope-floor-contract.test.ts.
scope: "write:catalog",
alsoRequires: ["write:people"],
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Re-evaluate a person against every cascade rule. Fires ipi_to_mlc_works (pulls MLC writer/publisher splits), " +
"mbid/isni→wikidata crosswalks, wikidata_to_awards, wikidata_to_biography, and the Google contact suggestion. " +
"Idempotent. Fire-and-forget. → then: pica_people_inspect (see what the cascade wrote)",
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: {
person_id: {
type: "string",
description: "The person ID to re-evaluate",
},
},
required: ["person_id"],
},
},
executor: this.runPersonCascade.bind(this),
},
{
definition: {
name: "pica_cascade_health",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Snapshot of the in-process enrichment cascade counters. " +
"Returns per-rule failure counts bucketed by reason (circuit_open, quota_exhausted, empty_result, timeout, auth_error, rate_limit, schema_error, network_error, unknown) " +
"plus outcome totals (wrote / no_match / already_enriched / error / proposed / suppressed). " +
"Process-global and not persistent across serverless cold starts — treat as a spot-check, not a historical ledger. " +
"Use when cascade enrichment looks unhealthy or after shipping new rules.",
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: {},
},
},
executor: this.cascadeHealth.bind(this),
},
// ============================================================
// ADR-163: Tier B enrichment proposal review tools
// ============================================================
{
definition: {
name: "pica_enrichment_proposals_list",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "List pending enrichment proposals awaiting review (Tier B fuzzy matches). " +
"These are matches from fuzzy-match cascade rules (MLC writer splits, " +
"Spotify artist+title, YouTube recording classification) that the cascade " +
"holds for human review instead of writing through automatically. " +
"Also carries pica's own QUESTIONS about uploaded audio (entity_type 'audio_file', " +
"source 'filename_reading'): when a file's name does not say whether it is the master, " +
"a version, an instrumental, a stem or a demo, pica suggests a file_type. The owner answers: " +
"pica_enrichment_proposal_apply with their chosen file_type (master, version, instrumental, " +
"demo or stem — the suggestion is only a default), or reject to keep the current type. " +
"Ask the user about each file, one at a time; do not guess for them and never bulk-apply these. " +
"Only pending proposals are returned — applied, rejected, and expired " +
"rows are invisible. Findings the catalogue has since ANSWERED by another " +
"route are excluded too: they stay `pending` in the table because nothing " +
"re-asks the question, but applying one refuses. `excluded_stale` reports " +
"how many and why, so a short page is distinguishable from a filtered one. " +
"Default order: newest first, limit 20 (max 100). " +
"Pass review_shape=true to get the same interpreted, grouped view the user sees at " +
"/inspect/found (read through the shared found-source registry, so it stays in step " +
"with the page). " +
"→ then: pica_enrichment_proposal_apply (accept a proposal), " +
"pica_enrichment_proposal_reject (dismiss a proposal), " +
"pica_enrichment_proposals_bulk_apply (accept a reviewed batch)",
workflows: ["enrichment-resolve-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: {
entity_type: {
type: "string",
enum: ["work", "person", "recording", "audio_file"],
description: "Filter to proposals anchored on a specific entity type. " +
"'audio_file' = pica's own questions about uploaded audio whose filename did not say what it is (source 'filename_reading').",
},
entity_id: {
type: "string",
description: "Filter to proposals anchored on a specific entity (pairs with entity_type)",
},
rule_id: {
type: "string",
description: "Filter by cascade rule id (e.g. 'ipi_to_mlc_works', 'artist_title_to_spotify', 'artist_title_to_youtube')",
},
source: {
type: "string",
description: "Filter by source ('mlc', 'spotify', 'youtube', 'wikidata', etc.)",
},
limit: {
type: "integer",
minimum: 1,
maximum: 100,
description: "Max results, 1..100 (default 20)",
},
offset: {
type: "integer",
description: "Pagination offset (default 0)",
},
min_confidence: {
type: "number",
description: "Only return proposals with confidence_score >= this value (0–100)",
},
max_confidence: {
type: "number",
description: "Only return proposals with confidence_score <= this value (0–100). " +
"Combine with pica_enrichment_proposals_bulk_reject to clear low-confidence noise.",
},
created_after: {
type: "string",
description: "Only return proposals created at or after this ISO 8601 timestamp. " +
"Use to scope results to the batch you just triggered.",
},
review_shape: {
type: "boolean",
description: "Also return `groups` — the SAME interpreted view the user sees at " +
"/inspect/found: grouped by source and confidence band, with before/after " +
"field changes, plain-language evidence lines, external links, and per-item " +
"`actionable` / `bulkEligible`. Prefer this when REVIEWING findings with a " +
"user, so you describe what they would see rather than raw columns. " +
"⚠️ `groups` is the WHOLE queue and is NOT narrowed by the filters above " +
"(those apply to `proposals` only). Costs extra lookups, so leave it off " +
"when you just need ids or a count.",
},
},
},
},
executor: this.listEnrichmentProposals.bind(this),
},
{
definition: {
name: "pica_enrichment_proposal_apply",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Apply a pending Tier B enrichment proposal. " +
"For update proposals (e.g. MLC writer splits on a work), drift " +
"detection runs first: if the entity has changed since the proposal " +
"was created, the response is { status: 'drift_detected', conflicts } " +
"and nothing is written. On drift, show the conflicts to the user " +
"before retrying with force=true. For create proposals (e.g. creating " +
"a YouTube recording on a work), a uniqueness check runs instead and " +
"cannot be forced. " +
"Prefer pica_enrichment_proposals_bulk_apply when accepting a reviewed batch; this one takes one id. " +
"FILE-TYPE QUESTIONS (entity_type 'audio_file', source 'filename_reading'): pass the type the USER " +
"chose as file_type — one of master, version, instrumental, demo, stem — and, for a stem, an optional " +
"stem_label (e.g. 'drums', max 60 characters). Omit file_type to accept pica's suggestion. Ask the user " +
"about each file and use their answer; never choose for them and never apply these in bulk. " +
"An invalid file_type, a stem_label on a non-stem, or file_type on any other kind of proposal is " +
"refused with INVALID_FILE_TYPE_ANSWER and nothing is written. " +
"→ then: pica_works_inspect (see the resolved work) " +
"or pica_recordings_inspect (see the resolved recording)",
workflows: ["enrichment-resolve-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: {
proposal_id: {
type: "string",
description: "The proposal id to apply",
},
force: {
type: "boolean",
description: "Bypass drift detection (update proposals only). Only set after the user has reviewed the conflicts from a prior drift_detected response.",
},
resolution_note: {
type: "string",
description: "Optional note recorded on the resolved row (free-form text)",
},
file_type: {
type: "string",
enum: ["master", "version", "instrumental", "demo", "stem"],
description: "File-type questions only (entity_type 'audio_file'): the type the USER chose for this file. " +
"Omit to accept pica's suggestion. Ask the user — never pick for them.",
},
stem_label: {
type: "string",
maxLength: 60,
description: "With file_type 'stem' only: a short name for the stem (e.g. 'drums', 'lead vocal'), as the user gave it.",
},
},
required: ["proposal_id"],
},
},
executor: this.applyEnrichmentProposal.bind(this),
},
{
definition: {
name: "pica_enrichment_proposal_reject",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "draft",
description: "Reject a pending Tier B enrichment proposal. " +
"Content-hash suppression permanently blocks re-proposal of the " +
"same exact content — the cascade will only re-propose if the " +
"source data genuinely changes (different MLC record, different " +
"Spotify match). No cooldown, no timer. Use when the proposal is " +
"wrong (homonym collision, stale source data, etc.) and you want " +
"the cascade to stop suggesting it. " +
"Prefer pica_enrichment_proposals_bulk_reject when clearing many at once; this one takes one id.",
workflows: ["enrichment-resolve-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: {
proposal_id: {
type: "string",
description: "The proposal id to reject",
},
resolution_note: {
type: "string",
description: "Optional reason recorded on the rejected row (e.g. 'wrong writer — homonym')",
},
},
required: ["proposal_id"],
},
},
executor: this.rejectEnrichmentProposal.bind(this),
},
// ============================================================
// ADR-264 C3: Bulk-reject enrichment proposals
// ============================================================
{
definition: {
name: "pica_enrichment_proposals_bulk_reject",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "draft",
description: "Reject multiple pending enrichment proposals in one call — by explicit ids OR a filter " +
"(rule_id / source / max_confidence). Caps at 100 per call. Same effect per proposal as " +
"pica_enrichment_proposal_reject (permanent content-hash suppression). Use after reviewing " +
"pica_enrichment_proposals_list with min/max_confidence filters to clear stale low-confidence noise.",
workflows: ["enrichment-resolve-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: {
proposal_ids: {
type: "array",
items: { type: "string" },
description: "Explicit list of proposal ids to reject (max 100). Mutually exclusive with filter.",
},
filter: {
type: "object",
description: "Filter-based selection — rejects all matching pending proposals (max 100). " +
"Mutually exclusive with proposal_ids.",
properties: {
rule_id: {
type: "string",
description: "Only reject proposals from this cascade rule",
},
source: {
type: "string",
description: "Only reject proposals from this source ('mlc', 'spotify', 'youtube', etc.)",
},
max_confidence: {
type: "number",
description: "Only reject proposals with confidence_score <= this value (0–100)",
},
},
},
resolution_note: {
type: "string",
description: "Optional reason recorded on all rejected rows (e.g. 'batch clear — source data stale')",
},
dry_run: {
type: "boolean",
description: "When true, returns { dry_run: true, would_affect, count } without rejecting anything. Use to preview the batch before committing.",
},
},
},
previewMode: "dry_run",
},
executor: this.bulkRejectEnrichmentProposals.bind(this),
},
{
definition: {
name: "pica_enrichment_proposals_bulk_apply",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "draft",
description: "Apply multiple pending enrichment proposals in one call, by explicit ids. Caps at 100 " +
"per call. Same effect per proposal as pica_enrichment_proposal_apply. Use after " +
"reviewing pica_enrichment_proposals_list — typically filtered with min_confidence — to " +
"accept a reviewed batch without one round trip per proposal. " +
"⚠️ PARTIAL SUCCESS IS NORMAL: each id is applied independently, so a non-empty `failed` " +
"beside a non-zero `applied` is the expected shape. Report both to the user; never call a " +
"partial a success. Ids that are no longer pending arrive in `failed` with their reason. " +
"Never include pica's file-type questions (entity_type 'audio_file') — each is the user's answer " +
"about one file; use pica_enrichment_proposal_apply with their file_type instead. " +
"→ then: pica_dashboard_briefing (surface the delta)",
workflows: ["enrichment-resolve-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: {
proposal_ids: {
type: "array",
items: { type: "string" },
description: "Explicit list of proposal ids to apply (max 100). Required — there is deliberately " +
"no filter form, because a mis-filtered bulk REJECT costs a re-proposal while a " +
"mis-filtered bulk APPLY writes to the catalogue.",
},
dry_run: {
type: "boolean",
description: "When true, returns { dry_run: true, would_affect, not_pending } and writes NOTHING. " +
"would_affect is an UPPER BOUND: an id pending now can still be refused at write time " +
"if the entity has drifted since the finding was made. Preview before committing a batch.",
},
},
required: ["proposal_ids"],
},
previewMode: "dry_run",
},
executor: this.bulkApplyEnrichmentProposals.bind(this),
},
// ============================================================
// ADR-178: Agent-proposed enrichment from open-web research
// ============================================================
{
definition: {
name: "pica_enrichment_propose",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "File an enrichment proposal sourced from open-web research. " +
"Use when you've found a fact about a work, recording, or person " +
"from an external site (Audiomack, Songdata, MusicBrainz page, " +
"label portal, artist bio, etc.) and want PICA to hold it pending " +
"review. Every proposed field MUST cite at least one source URL — " +
"unsourced claims are rejected. The server hard-codes source and " +
"rule_id to 'agent_research'; you do not set them. " +
"IN-SESSION APPROVAL IS THE CANONICAL FLOW: if the user is in " +
"this chat, ask for their approval directly, then call " +
"pica_enrichment_proposal_apply after their confirmation. Do not " +
"wait for the dashboard. The proposal row is the receipt, not " +
"the workflow. " +
"→ then: pica_enrichment_proposal_apply (apply after user approves), " +
"pica_enrichment_proposal_reject (dismiss if user disagrees)",
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: {
entity_type: {
type: "string",
enum: ["work", "person", "recording", "agreement"],
description: "For update: what kind of entity this proposal anchors on. For create: what TYPE is being created (recording / agreement / person). Work + publisher creation are deferred.",
},
entity_id: {
type: "string",
description: "The entity id in the caller's org. Required for update proposals; omit for create — parent_entity_id carries the attach point instead.",
},
proposed_fields: {
type: "object",
description: "The fields to propose writing. Every key here must appear in at least one sources[].fields array — otherwise MISSING_SOURCE is returned. Example: { tempo_bpm: 110, musical_key: 'D minor' }",
additionalProperties: true,
},
sources: {
type: "array",
description: "Per-field URL citations. Each entry names the fields it sources. One URL can cite multiple fields; one field can be cited by multiple URLs. Non-HTTP URIs (gmail:// gdrive:// notion:// file:// telegram:// user://) are accepted verbatim for sources behind paywalls or in private channels.",
items: {
type: "object",
properties: {
url: {
type: "string",
description: "The URL the fact was retrieved from",
},
fields: {
type: "array",
items: { type: "string" },
description: "Which proposed_fields keys this URL sources (non-empty)",
},
accessed_at: {
type: "string",
description: "ISO 8601 timestamp of when you accessed the URL",
},
},
required: ["url", "fields", "accessed_at"],
},
},
confidence: {
type: "number",
description: "Self-reported confidence 0..1 (optional). Surface cross-source agreement, match uncertainty, or source quality here.",
},
rationale: {
type: "string",
description: "Freeform explanation of how the facts were cross-referenced. Shown on the review surface alongside the sources.",
},
proposal_action: {
type: "string",
enum: ["update", "create"],
description: "Defaults to 'update'. 'create' spawns a new recording / agreement / person on a parent entity; pair with parent_entity_type + parent_entity_id. Review queue is mandatory — no silent auto-apply.",
},
parent_entity_type: {
type: "string",
enum: ["work", "recording", "agreement", "person"],
description: "For create proposals: the parent entity type. Recording creates MUST set parent_entity_type='work'; agreement creates may optionally link to a work; person creates can omit parent.",
},
parent_entity_id: {
type: "string",
description: "For create proposals: the parent entity id. Paired with parent_entity_type.",
},
},
required: ["entity_type", "proposed_fields", "sources"],
},
},
executor: this.proposeAgentResearch.bind(this),
},
// ============================================================
// ADR-289 Wave C2 — undo a wrong MusicBrainz match
// ============================================================
{
definition: {
name: "pica_enrichment_clear",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "destructive",
description: "Undo a MusicBrainz enrichment match on a work — permanently clears the ISWC and/or PRO " +
"IDs a prior MusicBrainz match wrote, but ONLY the ones that actually came from " +
"MusicBrainz (manually-entered values, or values from another source, are left alone). " +
"This cannot be undone: the cleared fields go back to null, not to whatever they held " +
"before the match. Use when a MusicBrainz match was wrong (homonym, wrong recording of " +
"the same title). No external calls are made — this only edits the work's own row. " +
"For a proposal not yet applied, use pica_enrichment_proposal_reject. " +
"→ then: pica_resolve_work (re-run enrichment), pica_works_inspect (confirm the fields cleared)",
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: "The work to clear MusicBrainz enrichment from",
},
clear_iswc: {
type: "boolean",
description: "Clear the ISWC if it came from MusicBrainz. Default true.",
},
clear_pro_ids: {
type: "boolean",
description: "Clear PRO IDs that came from MusicBrainz — PRO IDs sourced elsewhere are kept. Default true.",
},
reason: {
type: "string",
description: "Why this is being cleared (e.g. 'wrong MusicBrainz match — homonym'). Recorded on the result, not persisted on the work.",
},
},
required: ["work_id", "reason"],
},
},
executor: this.clearEnrichment.bind(this),
},
];
}
async enrichmentCandidates(_args) {
const result = await this.pica.enrichment.getCandidates();
return formatAsText(result);
}
/**
* ADR-179 `pica_resolve_work` executor — fan a work out across
* eligible enrichment sources and return the structured receipt.
*
* `isError` is set only when every source that ran errored. Empty
* applied[] + empty proposals[] + empty errors[] is a valid result
* (e.g. work has no trigger identifiers) and surfaces as a
* non-error response with a human-readable note.
*/
async resolveWork(args) {
const workId = args.work_id;
const sources = args.sources;
const includeFuzzy = typeof args.include_fuzzy === "boolean" ? args.include_fuzzy : undefined;
const force = args.force === true ? true : undefined;
const result = await this.pica.enrichment.resolveWork(workId, {
sources,
includeFuzzy,
force,
});
// isError semantics per ADR-179 Decision 1: partial failure is not
// an error. Only when every source that actually ran errored do we
// flip the flag.
const ranCount = result.sources_run.length;
const errorCount = result.errors.length;
const allErrored = ranCount > 0 && errorCount >= ranCount;
const summary = `resolve work ${workId} — ` +
`${result.applied.length} applied, ` +
`${result.proposals.length} proposals, ` +
`${result.overwrote?.length ?? 0} overwrote, ` +
`${result.skipped_lower_authority?.length ?? 0} kept (authority), ` +
`${errorCount} errors (ran: ${result.sources_run.join(", ") || "none"})`;
return {
content: [{ type: "text", text: summary }],
structuredContent: result,
isError: allErrored,
};
}
/**
* ADR-179 Phase 3 `pica_resolve_recording` executor — fan a recording
* out across eligible platform sources and return the structured
* receipt. Same isError semantics as resolveWork / resolvePerson.
*/
async resolveRecording(args) {
const recordingId = args.recording_id;
// ops_issue f577d83c: the agent passed an ISRC instead of a recording_id, so
// recording_id was undefined and the SDK built
// /admin/recordings/undefined/resolve → "invalid input syntax for type uuid:
// undefined" → 500. resolve_recording is keyed by the recording UUID and
// fills isrc FROM Spotify; it does NOT resolve BY isrc. Reject a
// missing/blank id with an actionable message instead of a raw uuid 500.
if (typeof recordingId !== "string" || recordingId.trim().length === 0) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "MISSING_REQUIRED",
message: "recording_id is required (a recording UUID). pica_resolve_recording resolves a recording by its id and does not look up by ISRC — find the recording first (e.g. pica_recordings_query) and pass its id.",
}, null, 2),
},
],
isError: true,
};
}
const sources = args.sources;
const includeFuzzy = typeof args.include_fuzzy === "boolean" ? args.include_fuzzy : undefined;
const force = args.force === true ? true : undefined;
const result = await this.pica.enrichment.resolveRecording(recordingId, {
sources,
includeFuzzy,
force,
});
const ranCount = result.sources_run.length;
const errorCount = result.errors.length;
const allErrored = ranCount > 0 && errorCount >= ranCount;
const summary = `resolve recording ${recordingId} — ` +
`${result.applied.length} applied, ` +
`${result.proposals.length} proposals, ` +
`${result.overwrote?.length ?? 0} overwrote, ` +
`${result.skipped_lower_authority?.length ?? 0} kept (authority), ` +
`${errorCount} errors (ran: ${result.sources_run.join(", ") || "none"})`;
return {
content: [{ type: "text", text: summary }],
structuredContent: result,
isError: allErrored,
};
}
/**
* `pica_resolve_release` executor — fan a release out against Spotify
* and return the structured receipt. Same isError semantics as
* resolveWork / resolveRecording.
*/
async resolveRelease(args) {
const releaseId = args.release_id;
if (typeof releaseId !== "string" || releaseId.trim().length === 0) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "MISSING_REQUIRED",
message: "release_id is required (a release UUID). pica_resolve_release resolves a release by its id — find the release first (e.g. pica_releases_query) and pass its id.",
}, null, 2),
},
],
isError: true,
};
}
const sources = args.sources;
const force = args.force === true ? true : undefined;
const result = await this.pica.enrichment.resolveRelease(releaseId, {
sources,
force,
});
const ranCount = result.sources_run.length;
const errorCount = result.errors.length;
const allErrored = ranCount > 0 && errorCount >= ranCount;
const summary = `resolve release ${releaseId} — ` +
`${result.applied.length} applied, ` +
`${result.proposals.length} proposals, ` +
`${result.overwrote?.length ?? 0} overwrote, ` +
`${result.skipped_lower_authority?.length ?? 0} kept (authority), ` +
`${errorCount} errors (ran: ${result.sources_run.join(", ") || "none"})`;
return {
content: [{ type: "text", text: summary }],
structuredContent: result,
isError: allErrored,
};
}
/**
* ADR-179 Phase 2 `pica_resolve_person` executor — fan a person out
* across eligible identity-graph sources and return the structured
* receipt. Mirrors `resolveWork`'s isError semantics: partial failure
* is a non-error; isError only flips when every source that actually
* ran errored.
*/
async resolvePerson(args) {
const personId = args.person_id;
const sources = args.sources;
const includeFuzzy = typeof args.include_fuzzy === "boolean" ? args.include_fuzzy : undefined;
const result = await this.pica.enrichment.resolvePerson(personId, {
sources,
includeFuzzy,
});
const ranCount = result.sources_run.length;
const errorCount = result.errors.length;
const allErrored = ranCount > 0 && errorCount >= ranCount;
const summary = `resolve person ${personId} — ` +
`${result.applied.length} applied, ` +
`${result.proposals.length} proposals, ` +
`${errorCount} errors (ran: ${result.sources_run.join(", ") || "none"})`;
return {
content: [{ type: "text", text: summary }],
structuredContent: result,
isError: allErrored,
};
}
async runWorkCascade(args) {
const result = await this.pica.enrichment.runWorkCascade(args.work_id);
return formatSuccess(`Cascade re-evaluation queued for work ${args.work_id}`, result);
}
async runPersonCascade(args) {
const result = await this.pica.enrichment.runPersonCascade(args.person_id);
return formatSuccess(`Cascade re-evaluation queued for person ${args.person_id}`, result);
}
async cascadeHealth(_args) {
const result = await this.pica.enrichment.getCascadeHealth();
return formatAsText(result);
}
// ============================================================
// ADR-163 Tier B proposal executors
// ============================================================
async listEnrichmentProposals(args) {
const result = await this.pica.enrichment.listEnrichmentProposals({
entity_type: args.entity_type,
entity_id: args.entity_id,
rule_id: args.rule_id,
source: args.source,
limit: args.limit,
offset: args.offset,
// ADR-264 C1: confidence + recency filters (sdk@>=1.37.1)
min_confidence: args.min_confidence,
max_confidence: args.max_confidence,
created_after: args.created_after,
review_shape: args.review_shape === true,
});
// The route returns {success, data: {proposals, count, filters}} and the
// SDK's request() narrows to `.data` — so this tool receives the OBJECT
// {proposals, count, filters}, never a bare array. The previous
// Array.isArray(result) guard therefore rejected EVERY real response as
// UNEXPECTED_RESPONSE_SHAPE (100% failure since 2026-06-10; ops_issues
// 18f25b41/20c76911). Unwrap the envelope's proposals array — a bare
// array is still accepted defensively — and keep the B1 (ADR-264-WS1b)
// guard for genuinely bad shapes so failures surface instead of
// becoming a phantom "? pending" count.
const envelope = result;
const proposals = Array.isArray(result)
? result
: Array.isArray(envelope?.proposals)
? envelope.proposals
: null;
if (proposals === null) {
const errorPayload = {
error_code: "UNEXPECTED_RESPONSE_SHAPE",
message: "enrichment proposals list returned an unexpected shape — treat as failure, retry once",
received_type: typeof result,
retry_eligible: true,
};
return {
content: [
{
type: "text",
text: JSON.stringify(errorPayload, null, 2),
},
],
structuredContent: errorPayload,
isError: true,
};
}
// ADR-193 Surface 2 — gap hints on empty-list so the agent is
// reminded it can seed proposals from peer-MCP evidence rather
// than leaving the user to type.
const gap_hints = computeProposalListGapHints(proposals);
/**
* ⚠️ The interpreted view has to be carried across this seam EXPLICITLY.
*
* The unwrap above narrows the route envelope to its `proposals` array,
* which is correct for every other caller and silently wrong for
* `review_shape=true`: the route runs `getFoundGroups` (three table
* lookups plus evidence and writers), returns `groups` alongside the
* proposals, and this executor used to drop them on the floor. The agent
* paid the cost of the interpreted read, was told by the tool description
* it was getting "the SAME interpreted view the user sees at
* /inspect/found", and received raw columns.
*
* Nothing could catch that below this line — the route's unit tests pass
* (the route is right), the tool's unit tests passed (they never asked for
* the shape), and TypeScript cannot see a field dropped from an `any`.
* Only an over-the-wire parity check does: `scenarios/catalog/
* found-agent-parity.spec.ts` compares both doors on one org in one run.
*
* Still opt-in: `groups` is present only when the route sent it, so an
* unflagged call stays byte-identical to a bare proposals array.
*/
const groups = Array.isArray(result)
? undefined
: envelope?.groups;
const payload = groups !== undefined || gap_hints.length > 0
? {
proposals,
...(gap_hints.length > 0 ? { gap_hints } : {}),
...(groups !== undefined ? { groups } : {}),
}
: proposals;
const groupCount = Array.isArray(groups) ? groups.length : 0;
const summary = proposals.length === 0
? "No pending proposals"
: `${proposals.length} pending proposal(s)` +
(groupCount > 0 ? ` in ${groupCount} reviewable group(s)` : "");
return formatSuccess(summary, payload);
}
async applyEnrichmentProposal(args) {
const proposalId = args.proposal_id;
const result = await this.pica.enrichment.applyEnrichmentProposal(proposalId, {
force: args.force === true,
resolution_note: args.resolution_note,
// Passed through untouched: the route validates both and refuses a
// bad answer with INVALID_FILE_TYPE_ANSWER, so a typo reaches the
// agent by name instead of being dropped here and the suggestion
// applied in its place.
...(args.file_type !== undefined ? { file_type: args.file_type } : {}),
...(args.stem_label !== undefined
? { stem_label: args.stem_label }
: {}),
});
// Accepting an MLC-writer `ask` puts a real co-writer in the catalogue who
// is not a PICA user. Say so, once, at the only moment the system knows it.
//
// ⚠️ This SENDS NOTHING. It names the tool and hands over the ids; the
// decision and the sending stay with the user.
//
// ⚠️ On the missing address: pica_collaborators_invite needs an email or a
// PICA handle, and PICA holds neither for most of these people (64 of 83
// IPI-carrying people are not users; 18 have an address on file, measured
// 2026-08-18). That is a gap in PICA's table, not in the world — the
// address is usually in the USER's own contacts or mail, which THIS agent
// can reach and PICA cannot. Ask the user before looking, and never put an
// address into PICA that the user did not choose to give.
const invitable = result.invitableWriter;
if (!invitable) {
return formatSuccess(`Enrichment proposal ${proposalId} — apply result`, result);
}
return formatSuccess(`Enrichment proposal ${proposalId} — apply result`, {
...result,
next_action: {
why: `${invitable.name} is now credited on this work and carries IPI ` +
`${invitable.ipi}, but has no PICA account. They cannot see or ` +
`confirm this credit until someone tells them.`,
tool: "pica_collaborators_invite",
arguments: {
work_id: invitable.workId,
name: invitable.name,
credit_type: "writer",
},
needs: "an `email` OR a PICA `handle`. PICA has neither on file for this " +
"person. If the user has the address in their own contacts or mail, " +
"ask them first, then pass it — do not go looking unprompted. If " +
"they have neither, say so plainly rather than inventing one.",
do_not: "Do not call this without the user asking. An invite is contact " +
"with a real person who has not heard of PICA.",
},
});
}
async rejectEnrichmentProposal(args) {
const proposalId = args.proposal_id;
const result = await this.pica.enrichment.rejectEnrichmentProposal(proposalId, {
resolution_note: args.resolution_note,
});
return formatSuccess(`Enrichment proposal ${proposalId} rejected`, result);
}
async bulkRejectEnrichmentProposals(args) {
// dry_run preview — what the real call would reject, resolved by the
// same server-side resolver (row-cap T3: the preview used to read the
// list route, which drops stale rows and stops at 100, so it could
// preview a different set from the one the real call rejects).
if (args.dry_run === true) {
const filter = args.filter;
const preview = await this.pica.enrichment.previewBulkRejectProposals({
proposal_ids: args.proposal_ids,
filter: filter
? {
rule_id: filter.rule_id,
source: filter.source,
max_confidence: filter.max_confidence,
}
: undefined,
});
return formatSuccess(preview.exceeds_cap
? `Dry run — more than ${preview.cap} proposals match; the real call would refuse. Narrow the filter.`
: "Dry run — no proposals rejected", { ...preview, filter: filter ?? null });
}
// Fix 5: direct typed SDK call — no `as any` cast needed now that the
// worktree node_modules resolve @withpica/mcp-sdk via the workspace symlink
// (npm install --workspaces --ignore-scripts + mcp:build:all was run).
const result = await this.pica.enrichment.bulkRejectProposals({
proposal_ids: args.proposal_ids,
filter: args.filter
? {
rule_id: args.filter.rule_id,
source: args.filter.source,
max_confidence: args.filter.max_confidence,
}
: undefined,
resolution_note: args.resolution_note,
});
const { rejected, skipped } = result;
const summary = `Bulk-rejected ${rejected} proposal(s)` +
(skipped?.length > 0
? ` (${skipped.length} skipped — not pending or not found)`
: "");
return formatSuccess(summary, result);
}
/**
* pica_enrichment_proposals_bulk_apply — the accept side of the
* /inspect/found queue, in one call.
*
* ⚠️ The summary names the FAILURES as well as the successes, deliberately.
* Applying loops per id across four different update/create paths, so
* partial success is the normal outcome, and a summary reading only
* "applied N" would let an agent report a clean success while proposals the
* user expected to land silently did not. On 2026-08-14 one org applied 53
* proposals one at a time and 8 of them declined as duplicates — under a
* bulk call with a success-only summary, that user would have been told 53
* were applied.
*/
async bulkApplyEnrichmentProposals(args) {
const result = await this.pica.enrichment.bulkApplyProposals({
proposal_ids: args.proposal_ids,
...(args.dry_run === true ? { dry_run: true } : {}),
});
// A preview has no `applied` — reporting "Bulk-applied undefined" would be
// the exact silent-drop this tool's contract test exists to catch.
if (result.dry_run === true) {
const blocked = result.not_pending?.length ?? 0;
return formatSuccess(`Dry run — ${result.would_affect ?? 0} proposal(s) WOULD apply, nothing written` +
(blocked > 0
? `; ${blocked} would not (see not_pending[] for the reason on each)`
: ""), result);
}
const { applied, failed } = result;
const failedCount = failed?.length ?? 0;
// Accepting a batch of asks puts real co-writers in the catalogue who are
// not PICA users. Bulk is the realistic path — twenty findings get accepted
// together — so a fact only the single apply surfaced was a fact nobody saw.
//
// ⚠️ SENDS NOTHING. Same contract as the single apply: it names the tool and
// the people; the decision and the sending stay with the user.
const invitable = result.invitableWriters ?? [];
const summary = `Bulk-applied ${applied} proposal(s)` +
(failedCount > 0
? ` — ${failedCount} did NOT apply (see failed[] for the reason on each)`
: "") +
(invitable.length > 0
? `; ${invitable.length} credited writer(s) are not on PICA — see next_action`
: "");
if (invitable.length === 0) {
return formatSuccess(summary, result);
}
return formatSuccess(summary, {
...result,
next_action: {
why: `${invitable.length} writer(s) credited by this batch carry an ` +
`industry ID and have no PICA account. They cannot see or confirm ` +
`these credits until someone tells them.`,
tool: "pica_collaborators_invite",
one_call_per_person: invitable.map((w) => ({
work_id: w.workId,
name: w.name,
credit_type: "writer",
})),
needs: "each invite needs an `email` OR a PICA `handle`. PICA has neither " +
"for these people. If the user has the addresses in their own " +
"contacts or mail, ask them first, then pass them — do not go " +
"looking unprompted, and never write an address into PICA the user " +
"did not choose to give.",
do_not: "Do not send these without the user asking, and do not send them as " +
"a batch just because they arrived as one. Each is contact with a " +
"real person who has not heard of PICA.",
},
});
}
/**
* ADR-178: pica_enrichment_propose.
*
* Delegates to PicaClient.enrichment.proposeAgentResearch(). Errors
* surface from the SDK as `ApiError` with the raw JSON response in
* the message. We extract the service-layer error code (MISSING_SOURCE,
* DUPLICATE_SUPPRESSED, etc.) from the parsed body and surface it
* in structuredContent so the agent can reason about the failure
* without parsing free-text.
*/
async proposeAgentResearch(args) {
try {
const result = await this.pica.enrichment.proposeAgentResearch({
entity_type: args.entity_type,
entity_id: args.entity_id,
proposed_fields: args.proposed_fields,
sources: args.sources,
confidence_score: args.confidence ?? null,
rationale: args.rationale,
proposal_action: args.proposal_action,
parent_entity_type: args.parent_entity_type,
parent_entity_id: args.parent_entity_id,
});
return formatSuccess(`Enrichment proposal ${result.proposal_id} filed (pending review)`, result);
}
catch (err) {
const parsed = extractApiErrorBody(err);
const code = parsed?.code ??
(err instanceof Error && err.message.includes("409")
? "DUPLICATE_SUPPRESSED"
: "REQUEST_FAILED");
const humanMessage = parsed?.error ?? (err instanceof Error ? err.message : "unknown error");
return {
content: [
{
type: "text",
text: `enrichment_propose failed — ${code}: ${humanMessage}`,
},
],
structuredContent: {
code,
error: humanMessage,
details: parsed?.details ?? null,
},
isError: true,
};
}
}
/**
* pica_enrichment_clear executor. Mirrors the retired bespoke assistant's
* handleClearMusicBrainzEnrichmentConfirmed field-by-field: reads the
* work's enrichment_metadata to check each field's actual source before
* clearing it (a manually-entered or non-MusicBrainz-sourced value is
* never touched), then writes only the cleared keys through the same
* pica_works_update backing route (PATCH /admin/works/:id) — no
* dedicated route, no external API call.
*
* Deliberate deviation from the bespoke original (whole-branch review,
* ADR-277 provenance is load-bearing): on a partial PRO clear, the bespoke
* handler nulled the ENTIRE enrichment_metadata.pro_ids block, wiping the
* surviving orgs' individual_sources provenance even though their actual
* pro_ids values were correctly kept. This version writes the surviving
* orgs' individual_sources back so their provenance is never lost.
*/
async clearEnrichment(args) {
const workId = args.work_id;
if (typeof workId !== "string" || workId.trim().length === 0) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "MISSING_REQUIRED",
message: "work_id is required (a work UUID).",
}, null, 2),
},
],
isError: true,
};
}
const reason = args.reason;
if (typeof reason !== "string" || reason.trim().length === 0) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "MISSING_REQUIRED",
message: "reason is required — record why this MusicBrainz-sourced data is being cleared.",
}, null, 2),
},
],
isError: true,
};
}
const clearIswc = args.clear_iswc !== false;
const clearProIds = args.clear_pro_ids !== false;
const work = await this.pica.works.get(workId);
// enrichment_metadata / pro_ids aren't on the shared SDK Work type (they
// are internal enrichment satellite fields, not part of the read
// contract external agents rely on) — read them defensively via a cast,
// same pattern as every other `as unknown as {...}` in this codebase.
const workRecord = work;
const enrichmentMetadata = workRecord.enrichment_metadata || {};
const updates = {};
const clearedFields = [];
const iswcMeta = enrichmentMetadata.iswc;
if (clearIswc && iswcMeta?.source === "musicbrainz") {
updates.iswc = null;
clearedFields.push("iswc");
updates.enrichment_metadata = { ...enrichmentMetadata, iswc: null };
}
const proIdsMeta = enrichmentMetadata.pro_ids;
if (clearProIds && proIdsMeta?.source === "musicbrainz") {
const currentProIds = workRecord.pro_ids || {};
const individualSources = proIdsMeta.individual_sources || {};
const remainingProIds = {};
const clearedProOrgs = [];
for (const [org, id] of Object.entries(currentProIds)) {
if (individualSources[org]?.source !== "musicbrainz") {
remainingProIds[org] = id;
}
else {
clearedProOrgs.push(org);
}
}
if (clearedProOrgs.length > 0) {
const hasSurvivingProIds = Object.keys(remainingProIds).length > 0;
updates.pro_ids = hasSurvivingProIds ? remainingProIds : null;
clearedFields.push(`pro_ids (${clearedProOrgs.join(", ")})`);
const currentEnrichmentMetadata = updates.enrichment_metadata ||
enrichmentMetadata;
if (hasSurvivingProIds) {
// ADR-277: provenance is load-bearing. A partial clear must keep
// the surviving (non-musicbrainz-sourced) orgs' individual_sources
// entries intact — only drop the entries for the orgs we actually
// cleared. Also demote the parent-level `source` off a bare
// "musicbrainz" claim since pro_ids is no longer wholesale
// musicbrainz-sourced once at least one org survives.
const survivingIndividualSources = {};
for (const org of Object.keys(remainingProIds)) {
if (individualSources[org]) {
survivingIndividualSources[org] = individualSources[org];
}
}
updates.enrichment_metadata = {
...currentEnrichmentMetadata,
pro_ids: {
...proIdsMeta,
source: "mixed",
individual_sources: survivingIndividualSources,
},
};
}
else {
updates.enrichment_metadata = {
...currentEnrichmentMetadata,
pro_ids: null,
};
}
}
}
if (Object.keys(updates).length === 0) {
return formatSuccess(`No MusicBrainz-sourced fields to clear on work ${workId}`, { work_id: workId, cleared_fields: [], reason });
}
const outcome = readChangeOutcome(await this.pica.works.update(workId, updates));
// In a client's catalogue a representative's change is a proposal:
// nothing was cleared yet (relationship-change.ts).
if (outcome.kind === "proposed")
return formatProposedChange("work", workId, clearedFields, outcome);
return formatSuccess(`Cleared MusicBrainz enrichment from work ${workId}: ${clearedFields.join(", ")}`, { work_id: workId, cleared_fields: clearedFields, reason });
}
}
/**
* Extract a structured error body from an SDK thrown error. The SDK's
* ApiError wraps the response body text in the message as
* "API request failed: {status} {body}". We pull the trailing JSON
* object out and parse it. Returns null on anything we can't parse —
* the caller falls back to the raw message.
*/
function extractApiErrorBody(err) {
if (!(err instanceof Error))
return null;
const match = err.message.match(/\{[\s\S]*\}/);
if (!match)
return null;
try {
return JSON.parse(match[0]);
}
catch {
return null;
}
}
//# sourceMappingURL=enrichment.js.map