UNPKG

@withpica/mcp-server

Version:

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

1,090 lines • 56.9 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
/**
 * People Tools - MCP tools for managing people (artists, composers, etc.)
 *
 * ADR-174 Phase 2 item 4 — pica_people_create / pica_people_update
 * write surfaces are now reconciled against public.people +
 * public.person_enrichment. `additionalProperties: true` is off;
 * unknown keys 400 at the route layer. Fields listed under
 * SATELLITE_ROUTED live on public.person_enrichment and are fanned
 * out from the service via syncPersonEnrichmentSatellite (see
 * lib/services/people-admin/enrichment-satellite.ts).
 */
import { ApiError } from "@withpica/mcp-sdk";
import { formatSuccess, formatStructuredList, withBillingGate, } from "@withpica/mcp-utils";
import { computePersonGapHints } from "./gap-hints.js";
// The two per-source `pica_people_enrich_*` tools were folded into
// `pica_resolve_person` by ADR-179 Phase 2 and removed in ADR-154 F13;
// the corresponding SDK enrich methods are no longer imported here.
/**
 * Canonical source for the pica_people_create / pica_people_update input
 * shape. Mirror of PEOPLE_CORE_WRITE_FIELDS + PEOPLE_SATELLITE_ROUTED_FIELDS
 * in lib/services/people-admin/write-constants.ts. Keep these two in sync:
 * if a property is added here it must be added to the allow-list (so the
 * HTTP route lets it through) and — if it's a satellite field — to
 * ENRICHMENT_SATELLITE_FIELDS (so the service routes it to person_enrichment).
 *
 * Exported for future bulk-tool reuse.
 */
export const PEOPLE_WRITE_PROPERTIES = {
    // names + identity
    first_name: { type: "string", description: "First name" },
    last_name: { type: "string", description: "Last name" },
    middle_name: { type: "string", description: "Middle name" },
    name: {
        type: "string",
        description: "Full name. Normally derived from first_name + last_name; set explicitly only for imports where the parts aren't separable.",
    },
    stage_name: {
        type: "string",
        description: "Primary stage / artist name (legacy singular)",
    },
    stage_names: {
        type: "array",
        items: { type: "string" },
        description: "All stage / artist names (canonical plural)",
    },
    person_type: {
        type: "array",
        items: { type: "string" },
        description: "Legacy alias — prefer record_kind and roles. A kind-of-record value (individual, placeholder, artefact) sets record_kind; any other value (e.g. collaborator, business) is added to roles. `type` is a deprecated duplicate and will be rejected.",
    },
    biography: { type: "string", description: "Biography text" },
    // contact
    email: { type: "string", description: "Primary email address" },
    emails: {
        type: "array",
        items: {
            type: "object",
            properties: {
                email: { type: "string" },
                label: { type: "string" },
                primary: { type: "boolean" },
            },
        },
        description: "Additional emails with label/primary metadata",
    },
    phone: { type: "string", description: "Phone number" },
    website: { type: "string", description: "Website URL" },
    social_media: {
        type: "object",
        description: "Social handles keyed by platform, e.g. { twitter: '@ada', instagram: 'ada_lovelace' }",
    },
    // roles + tags
    roles: {
        type: "array",
        items: { type: "string" },
        description: "Roles (e.g. ['artist', 'songwriter', 'producer']). Canonical column — `role` singular is a deprecated duplicate and will be rejected. Defaults to ['collaborator'] on create if omitted.",
    },
    tags: {
        type: "array",
        items: { type: "string" },
        description: "Tags for categorisation",
    },
    is_group: {
        type: "boolean",
        description: "whether this person record represents a group/band rather than an individual",
    },
    // PRO / industry identifiers on people itself
    cae_ipi_number: {
        type: "string",
        description: "CAE/IPI number (songwriter/publisher identifier, 9-11 digits)",
    },
    // ops dfa810f8 — every other surface (works, publishers, search) says
    // `ipi`; the people routes fold it onto cae_ipi_number
    // (normalizePeopleWriteAliases in lib/services/people-admin/write-constants.ts).
    ipi: {
        type: "string",
        description: "Same as cae_ipi_number (accepted under the name other tools use). Send one or the other — if both are sent they must be the same IPI, or the call is refused.",
    },
    ipn_number: {
        type: "string",
        description: "IPN number (performer identifier)",
    },
    // ADR-282: replaces per-society columns (prs_number, ppl_number,
    // mcps_member_number) with a generic array so no single society is
    // privileged by a dedicated field.
    society_affiliations: {
        type: "array",
        description: "Society affiliations for this person, e.g. [{ society_code: 'PRS', membership_number: '...' }].",
        items: {
            type: "object",
            properties: {
                society_code: {
                    type: "string",
                    description: "collecting society code, e.g. PRS, PPL, MCPS",
                },
                membership_number: {
                    type: "string",
                    description: "the person's membership number with this society (optional)",
                },
                // ADR-292: is this the society the person is a current member of,
                // or their home-country origin society for that code.
                affiliation_kind: {
                    type: "string",
                    enum: ["member", "origin"],
                    description: "member (current) or origin (home-country society) — defaults to member",
                },
            },
            required: ["society_code"],
        },
    },
    pro_name: {
        type: "string",
        description: "Performing rights organisation name (e.g. PRS, ASCAP, BMI, SACEM)",
    },
    pro_member_number: {
        type: "string",
        description: "Member number at the PRO named in pro_name",
    },
    default_publisher: {
        type: "string",
        description: "Default publisher name for this person",
    },
    default_publisher_ipi: {
        type: "string",
        description: "Default publisher's IPI number",
    },
    // business-contact fields
    company: { type: "string", description: "Company / organisation name" },
    position: {
        type: "string",
        description: "Job title / position at the company",
    },
    territory: {
        type: "string",
        description: "Primary territory / market (e.g. 'UK', 'US East Coast')",
    },
    // ADR-292: real text[] column on public.people, sanitized server-side.
    origin_countries: {
        type: "array",
        items: { type: "string" },
        description: "ISO 3166-1 alpha-2 heritage countries (multiple allowed) — the creator's self-identified origin",
    },
    relationship_strength: {
        type: "integer",
        description: "Relationship strength score (caller-defined scale)",
    },
    // contact prefs
    email_preferences: {
        type: "object",
        description: "Email opt-in preferences, e.g. { monthly_digest: true, news_announcements: false }",
    },
    // presence
    profile_image_url: {
        type: "string",
        description: "URL to profile image",
    },
    // notes
    notes: { type: "string", description: "Internal notes" },
    // ---- Satellite-routed (person_enrichment) ----
    // These are real columns on public.person_enrichment, not on public.people.
    // The service layer (lib/services/people-admin/enrichment-satellite.ts)
    // fans them out via syncPersonEnrichmentSatellite. Reads surface them
    // back on the person record via a PostgREST join (see person-admin/
    // constants.ts PERSON_ALL_COLUMNS -> person_enrichment(*)).
    isni: {
        type: ["string", "null"],
        description: "ISNI identifier (16 digits). Stored on person_enrichment satellite. Send null to clear a wrong value.",
    },
    musicbrainz_id: {
        type: ["string", "null"],
        description: "MusicBrainz artist ID (UUID). Stored on person_enrichment satellite. Send null to clear.",
    },
    wikidata_id: {
        type: ["string", "null"],
        description: "Wikidata entity ID (e.g. Q7259). Stored on person_enrichment satellite. Send null to clear a wrong match — enrichment will then not re-apply that entity's identifiers.",
    },
    discogs_artist_id: {
        type: ["string", "null"],
        description: "Discogs artist ID. Stored on person_enrichment satellite. Send null to clear.",
    },
    deezer_artist_id: {
        type: ["string", "null"],
        description: "Deezer artist ID. Stored on person_enrichment satellite. Send null to clear.",
    },
    imdb_id: {
        type: ["string", "null"],
        description: "IMDb name ID (e.g. nm0000001). Stored on person_enrichment satellite. Send null to clear.",
    },
    viaf_id: {
        type: ["string", "null"],
        description: "VIAF identifier. Stored on person_enrichment satellite. Send null to clear.",
    },
    date_of_birth: {
        type: ["string", "null"],
        description: "Date of birth (ISO 8601, YYYY-MM-DD). Stored on person_enrichment satellite. Send null to clear.",
    },
    date_of_death: {
        type: ["string", "null"],
        description: "Date of death (ISO 8601, YYYY-MM-DD). Stored on person_enrichment satellite. Send null to clear.",
    },
    gender: {
        type: ["string", "null"],
        description: "Gender (free text; commonly 'male' / 'female' / 'non_binary'). Stored on person_enrichment satellite. Send null to clear.",
    },
    nationality: {
        type: ["string", "null"],
        description: "Nationality (free text; ISO country name preferred). Stored on person_enrichment satellite. Send null to clear.",
    },
    nationality_wikidata_id: {
        type: ["string", "null"],
        description: "Wikidata entity ID of the nationality (e.g. Q145). Stored on person_enrichment satellite. Send null to clear.",
    },
    birth_place: {
        type: ["string", "null"],
        description: "Place of birth (free text). Stored on person_enrichment satellite. Send null to clear.",
    },
    birth_place_wikidata_id: {
        type: ["string", "null"],
        description: "Wikidata entity ID of the place of birth. Stored on person_enrichment satellite. Send null to clear.",
    },
    instruments: {
        type: ["array", "null"],
        items: { type: "string" },
        description: "Instruments played. Stored on person_enrichment satellite. Send [] or null to clear.",
    },
    career_start_year: {
        type: ["integer", "null"],
        description: "Year career started (4-digit). Stored on person_enrichment satellite. Send null to clear.",
    },
    languages_spoken: {
        type: ["array", "null"],
        items: { type: "string" },
        description: "Languages spoken. Stored on person_enrichment satellite. Send [] or null to clear.",
    },
};
export class PeopleTools {
    pica;
    constructor(pica) {
        this.pica = pica;
    }
    /**
     * Get all people tools
     */
    getTools() {
        return [
            {
                definition: {
                    name: "pica_people_query",
                    scope: "read:people",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Find people by name, role, or other criteria. " +
                        "Filter `record_kind` to focus on individuals vs placeholder/artefact rows that need cleanup. " +
                        "Filter `outreach_state` to find rows ready to contact, or to exclude opted-out / undeliverable rows. " +
                        "→ then: pica_people_inspect (details for a specific person). " +
                        "Filter by presence (has_email / has_phone) or by roles[] / tags[] (array-overlap). These apply when browsing without a free-text query.",
                    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: {
                            query: {
                                type: "string",
                                description: "Search by name, email, or IPI",
                            },
                            role: {
                                type: "string",
                                description: "Filter by role (e.g. artist, songwriter, producer)",
                            },
                            record_kind: {
                                type: "string",
                                enum: ["individual", "placeholder", "artefact"],
                                description: "Filter by kind of people record. " +
                                    "`individual` — real person (default for new rows). " +
                                    "`placeholder` — credit recorded with insufficient identity (e.g. 'Producer', stage name only). " +
                                    "`artefact` — row created by a parsing artefact (e.g. concatenated entries like 'John / Jane').",
                            },
                            outreach_state: {
                                type: "string",
                                enum: [
                                    "ready",
                                    "needs_email",
                                    "needs_disambiguation",
                                    "not_a_target",
                                    "do_not_contact",
                                    "previously_declined",
                                ],
                                description: "Filter by readiness to contact. " +
                                    "`ready` — has email, no opt-out. " +
                                    "`needs_email` — identifiable individual without contact channel. " +
                                    "`needs_disambiguation` — multiple candidate identities. " +
                                    "`not_a_target` — outreach structurally inapplicable (publisher/placeholder). " +
                                    "`do_not_contact` — hard opt-out, honoured by pica_collaborators_invite. " +
                                    "`previously_declined` — informational downweight (does not gate any tool).",
                            },
                            has_email: {
                                type: "boolean",
                                description: "Filter to people who have (true) / lack (false) an email address.",
                            },
                            has_phone: {
                                type: "boolean",
                                description: "Filter to people who have (true) / lack (false) a phone number.",
                            },
                            roles: {
                                type: "array",
                                items: { type: "string" },
                                description: "Filter to people holding ANY of these roles (array-overlap). Server-side; distinct from the singular `role` (client-side exact match).",
                            },
                            tags: {
                                type: "array",
                                items: { type: "string" },
                                description: "Filter to people carrying ANY of these tags (array-overlap).",
                            },
                            limit: {
                                type: "number",
                                description: "Max results (default: 50)",
                            },
                            offset: {
                                type: "number",
                                description: "Pagination offset",
                            },
                        },
                    },
                },
                executor: this.queryPeople.bind(this),
            },
            {
                definition: {
                    name: "pica_people_inspect",
                    scope: "read:people",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Get the full picture on one person — find the id with " +
                        "pica_people_query. Use when the user asks: " +
                        "'tell me about this person', 'show me everything on X', 'what do " +
                        "you know about artist X?', 'everything connected to X', 'what " +
                        "have I done with X?'. " +
                        'sections:["connections"] returns the org-catalog ' +
                        "neighbourhood — works, recordings, releases, agreements as " +
                        "{kind,id,name,via} with per-kind counts (capped; counts stay " +
                        "exact). For the GLOBAL identity graph by identifier use " +
                        "pica_graph_traverse instead. → " +
                        "then: pica_credits_update (add them to a work), " +
                        "pica_resolve_person (fill identifiers from ISNI/MusicBrainz)",
                    workflows: "infrastructure",
                    vernacular_kind: "creator-entry",
                    inputSchema: {
                        type: "object",
                        properties: {
                            id: {
                                type: "string",
                                description: "Person ID",
                            },
                            sections: {
                                type: "array",
                                items: {
                                    type: "string",
                                    enum: [
                                        "basic",
                                        "credits",
                                        "recording_credits",
                                        "agreements",
                                        "identifiers",
                                        "enrichment",
                                        "connections",
                                        "global_identity",
                                    ],
                                },
                                description: 'Which data sections to include. Omit for all sections. Use ["basic"] for lightweight lookup. ' +
                                    '"global_identity" (only when named) returns the person\'s global identity: its identifiers, other people in this catalogue sharing it, ' +
                                    "unattributed split rows and open custody legs naming it, and which of the person's identifiers equal it — read it before pica_people_split_identity.",
                            },
                        },
                        required: ["id"],
                    },
                },
                executor: this.inspectPerson.bind(this),
            },
            {
                definition: {
                    name: "pica_people_create",
                    scope: "write:people",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Add a new person to the catalog. Use when the user asks: 'add " +
                        "this person', 'create a new artist', 'add a songwriter'. Always " +
                        "run pica_people_query first to check they're not already there — " +
                        "duplicate people break credits and notifications. Use first_name " +
                        "and last_name (and optional middle_name); don't send full_name, " +
                        "type, or role — the request will be rejected (use person_type " +
                        "and roles instead). You can set external identifiers (isni, " +
                        "musicbrainz_id, wikidata_id, discogs_artist_id, " +
                        "deezer_artist_id, imdb_id, viaf_id) and biographical fields " +
                        "(date_of_birth, date_of_death, gender, nationality, " +
                        "birth_place, instruments, career_start_year, " +
                        "languages_spoken) right here — they're stored on a related " +
                        "table (a 'satellite' — extra fields off the main person row, " +
                        "person_enrichment) and the service fans them out. " +
                        "→ before: pica_people_query " +
                        "(check duplicates) → then: pica_resolve_person (fill identifiers " +
                        "from ISNI/MusicBrainz), pica_credits_update (add to a work)",
                    workflows: ["person-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: PEOPLE_WRITE_PROPERTIES,
                        required: ["first_name", "last_name"],
                    },
                },
                executor: withBillingGate(this.pica, "person creation", this.createPerson.bind(this)),
            },
            {
                definition: {
                    name: "pica_people_update",
                    scope: "write:people",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Update an existing person record. Pass only the fields to change. " +
                        "External identifiers (isni, musicbrainz_id, wikidata_id, discogs_artist_id, deezer_artist_id, imdb_id, viaf_id) and biographical fields (date_of_birth, date_of_death, gender, nationality, nationality_wikidata_id, birth_place, birth_place_wikidata_id, instruments, career_start_year, languages_spoken) are stored on a related table (a 'satellite' — extra fields off the main person row, person_enrichment) — updating them here fans out through the service. " +
                        "Every field enrichment fills can be corrected or cleared here: send null (or [] for a list) to remove a wrong value. A field you set or clear is marked as yours, and later enrichment runs leave it alone. " +
                        "Deprecated duplicates that will be rejected: `type` (use `person_type`), `role` (use `roles`). " +
                        "If setting email, the error code PERSON_EMAIL_EXISTS means the email already exists on another person record — find and reuse it via pica_people_query. " +
                        "To set an artist photo / avatar / profile picture from a URL, prefer pica_multimedia_import_url with person_id (downloads and stores it); profile_image_url here hotlinks an existing public image without storing it. " +
                        "→ then: pica_people_inspect (verify changes persisted)",
                    workflows: ["person-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: "Person ID (required)",
                            },
                            ...PEOPLE_WRITE_PROPERTIES,
                        },
                        required: ["id"],
                    },
                },
                executor: withBillingGate(this.pica, "person update", this.updatePerson.bind(this)),
            },
            {
                definition: {
                    name: "pica_people_delete",
                    scope: "write:people",
                    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 person record. For duplicates prefer pica_merge_duplicates so credits move " +
                        "to the winner instead of vanishing — check their credits first with pica_people_inspect.",
                    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: "Person UUID from pica_people_query.",
                            },
                        },
                        required: ["id"],
                    },
                },
                executor: withBillingGate(this.pica, "person deletion", this.deletePerson.bind(this)),
            },
            {
                definition: {
                    name: "pica_people_bulk_delete",
                    scope: "write:people",
                    // ADR-250 — connector-reachable, gated by out-of-band human approval
                    // (approvalMode:"human" below) rather than blanket connector exclusion.
                    audiences: ["stdio", "http", "public"],
                    tier: "destructive",
                    // ADR-250 V1 — the one destructive catalog tool with a server-side
                    // executor (human-approval-executor EXECUTORS). A delegated-agent
                    // caller proposes; the grant owner approves out-of-band; PICA replays
                    // the frozen params server-side. Session + first-party-key callers
                    // fall through to the existing dry_run/destructive path unchanged.
                    approvalMode: "human",
                    description: "Delete multiple people at once. For a single person use " +
                        "pica_people_delete; for duplicates prefer pica_merge_duplicates so " +
                        "credits move to the winner instead of vanishing.",
                    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 person IDs to delete",
                                items: { type: "string" },
                            },
                            dry_run: {
                                type: "boolean",
                                description: "When true, returns { dry_run: true, would_affect, target_count } without deleting any person. Preview before committing — bulk deletes are soft-deletes; credits and agreements remain linked to the now-removed person records.",
                            },
                        },
                        required: ["ids"],
                    },
                    previewMode: "dry_run",
                },
                executor: withBillingGate(this.pica, "bulk person deletion", this.bulkDeletePeople.bind(this)),
            },
            {
                definition: {
                    name: "pica_people_split_identity",
                    scope: "write:people",
                    audiences: ["stdio", "http", "public"],
                    tier: "destructive",
                    // Identity-level, like pica_merge_duplicates: a delegated agent
                    // (OAuth connector / pica_grant_*) proposes, the org owner approves
                    // out-of-band, PICA replays the frozen params via
                    // human-approval-executor.
                    approvalMode: "human",
                    description: "Give one person a NEW global identity when two different people in this catalogue share one " +
                        "(e.g. a wrong ISNI joined them, so custody transfers land as 'unknown'). The opposite of a merge — " +
                        "use pica_merge_duplicates when they are the SAME person. " +
                        'First read pica_people_inspect {id, sections:["global_identity"]}: it names who shares the identity, ' +
                        "its identifiers, the unattributed split rows naming it, open custody legs naming it, and which of " +
                        "this person's own identifiers equal it. Nothing is guessed: identifiers stay with the original identity " +
                        "unless listed in move_identifiers, and only split rows listed in relink_split_ids move to this person. " +
                        "clear_person_identifiers removes identifiers that were never this person's from their own row; " +
                        "undo_external_ids strips every enrichment field a wrong external record (e.g. a Wikidata Q-id) wrote. " +
                        "Only split rows whose note names the shared identity can be relinked. Identifiers cannot be moved off an identity " +
                        "that people in OTHER organisations also link to (SPLIT_IDENTITY_USED_ELSEWHERE; the global_identity section shows the count). " +
                        "Custody legs are append-only and never rewritten — record a new handoff with pica_recording_custody_assign. " +
                        "For delegated agents this creates a pending human approval — relay the message and poll pica_approval_status.",
                    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: "Person UUID to split off onto a new identity (from pica_people_query).",
                            },
                            move_identifiers: {
                                type: "array",
                                items: {
                                    type: "string",
                                    enum: [
                                        "isni",
                                        "ipi",
                                        "ipn",
                                        "musicbrainz",
                                        "wikidata",
                                        "discogs",
                                        "deezer",
                                        "viaf",
                                    ],
                                },
                                description: "Identifiers on the shared identity that belong to THIS person and move with them. " +
                                    "Default none — every identifier stays with the original identity. Ask the user; never infer.",
                            },
                            relink_split_ids: {
                                type: "array",
                                items: { type: "string" },
                                description: "Unattributed recording split row ids (person 'unknown') to give this person — " +
                                    "take them from the global_identity section's unattributed_split_rows and confirm each with the user.",
                            },
                            clear_person_identifiers: {
                                type: "array",
                                items: {
                                    type: "string",
                                    enum: [
                                        "isni",
                                        "ipi",
                                        "ipn",
                                        "musicbrainz",
                                        "wikidata",
                                        "discogs",
                                        "deezer",
                                        "viaf",
                                    ],
                                },
                                description: "Identifiers to remove from THIS person's own record because they were never theirs. " +
                                    "Cannot overlap move_identifiers.",
                            },
                            undo_external_ids: {
                                type: "array",
                                items: { type: "string" },
                                description: "External record ids (e.g. a Wikidata Q-id like Q16539088) whose enrichment was wrongly " +
                                    "applied to this person — every field that record wrote is stripped from their record.",
                            },
                        },
                        required: ["id"],
                    },
                },
                executor: withBillingGate(this.pica, "person identity split", this.splitPersonIdentity.bind(this)),
            },
            {
                definition: {
                    name: "pica_artist_projects",
                    scope: "write:people",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Manage a person's artist projects — the per-stage-name Spotify/YouTube " +
                        "identity PICA has matched or enriched (followers, popularity, genres, " +
                        "subscriber counts). A person can have multiple artist projects (e.g. a " +
                        "solo name and a duo name) with one marked primary.\n\n" +
                        "action: 'list' (default) — list every artist project for person_id.\n" +
                        "action: 'enrich' — trigger Spotify/YouTube matching for every stage name " +
                        "on person_id; confident matches apply automatically, ambiguous ones need " +
                        "pica_people_update to resolve manually.\n" +
                        "action: 'set_primary' — mark project_id as the primary artist project for " +
                        "its person (unsets any other primary automatically).\n\n" +
                        "→ then: pica_people_inspect (see the person's current stage names)",
                    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: {
                            action: {
                                type: "string",
                                enum: ["list", "enrich", "set_primary"],
                                description: "Which operation to perform. Default 'list'.",
                            },
                            person_id: {
                                type: "string",
                                description: "Person ID. Required for action 'list' and 'enrich'.",
                            },
                            project_id: {
                                type: "string",
                                description: "Artist project ID. Required for action 'set_primary'.",
                            },
                        },
                        required: ["action"],
                    },
                },
                executor: withBillingGate(this.pica, "artist project management", this.artistProjects.bind(this)),
            },
            {
                definition: {
                    name: "pica_people_triangulate",
                    scope: "read:people",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    annotations: { title: "triangulate artist", readOnlyHint: true },
                    description: "Check whether an external artist name already matches an existing person in the " +
                        "catalog. Useful when a recording credit or import gives you a raw artist name (and " +
                        "maybe a MusicBrainz id) and you need to know whether to link an existing person or " +
                        "create a new one. Tries, in order: exact MusicBrainz id match, then ISNI (looked up " +
                        "via MusicBrainz), then a fuzzy name match. Returns confidence (high/medium/low/none), " +
                        "the matched person (if any), why it matched, and alternative candidates worth a " +
                        "second look. Read-only — makes no changes.\n\n" +
                        "→ then: pica_people_create (no match — create a new person), pica_credits_update " +
                        "(match found — attach the credit), pica_resolve_person (fill remaining identifiers " +
                        "once linked)",
                    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: {
                            artist_name: {
                                type: "string",
                                description: "The raw artist name to match against existing people.",
                            },
                            musicbrainz_id: {
                                type: "string",
                                description: "Optional MusicBrainz artist id, if you have one — enables the exact-id and ISNI match tiers.",
                            },
                        },
                        required: ["artist_name"],
                    },
                },
                executor: this.triangulateArtist.bind(this),
            },
        ];
    }
    /**
     * Query people — unified list + search
     */
    async queryPeople(args) {
        const limit = args.limit || 50;
        const offset = args.offset || 0;
        // ADR-244 §3.1 / §3.2: server-side filters threaded through the SDK
        // so the index does the work (composite (org_id, enum) per migration
        // 20260522132950).
        const recordKind = args.record_kind;
        const outreachState = args.outreach_state;
        // Structured recall (Task 6): server-side presence/array filters. Only
        // applied by the service on the standard-filters branch (no query/
        // letter) — documented on the tool description.
        const hasEmail = args.has_email;
        const hasPhone = args.has_phone;
        const roles = args.roles;
        const tags = args.tags;
        let items;
        let total;
        // When any server-side filter is requested (query, record_kind,
        // outreach_state, has_email, has_phone, roles, tags) OR the caller is
        // paginating, route through .search() so the API applies them. The
        // plain list path stays for the cheap first-page case — but it neither
        // forwards offset nor returns a real total, so offset>0 through it
        // silently re-served page one forever (ops issue 091e2bff: 205 of 255
        // people unreachable).
        if (args.query ||
            recordKind ||
            outreachState ||
            hasEmail !== undefined ||
            hasPhone !== undefined ||
            roles ||
            tags ||
            offset > 0) {
            const result = await this.pica.people.search({
                query: args.query,
                limit,
                offset,
                record_kind: recordKind,
                outreach_state: outreachState,
                has_email: hasEmail,
                has_phone: hasPhone,
                roles,
                tags,
            });
            items = result.data;
            total = result.total;
        }
        else {
            // Plain list
            const people = await this.pica.people.list({ limit });
            items = people;
            total = people.length;
        }
        // Apply role filter client-side if provided
        if (args.role) {
            const roleLower = args.role.toLowerCase();
            items = items.filter((person) => {
                const roles = person.roles || [];
                return roles.some((r) => r.toLowerCase() === roleLower);
            });
            total = items.length;
        }
        return formatStructuredList(items, "person", {
            query: args.query,
            total,
            limit,
        });
    }
    /**
     * Inspect person — unified get + get_full with section filtering
     */
    async inspectPerson(args) {
        // ops 482424b7 — the global identity view is fetched only when named: it
        // is a separate read (shared-identity diagnosis before a split), not part
        // of the default full picture.
        if (Array.isArray(args.sections) &&
            args.sections.includes("global_identity")) {
            const rest = args.sections.filter((section) => section !== "global_identity");
            const identity = await this.pica.people.splitIdentityPreview(args.id);
            const sharedWith = Array.isArray(identity?.shared_with)
                ? identity.shared_with.length
                : 0;
            const identityResult = formatSuccess(sharedWith > 0
                ? `This person shares a global identity with ${sharedWith} other ${sharedWith === 1 ? "person" : "people"} in this catalogue.`
                : "This person's global identity is not shared with anyone else in this catalogue.", { global_identity: identity });
            if (rest.length === 0)
                return identityResult;
            const base = await this.inspectPerson({ ...args, sections: rest });
            return {
                ...base,
                content: [...base.content, ...identityResult.content],
            };
        }
        const sections = args.sections;
        const SECTION_COUNT = 7; // basic, credits, recording_credits, agreements, identifiers, enrichment, connections
        const allSections = !sections || sections.length === 0 || sections.length === SECTION_COUNT;
        if (sections && sections.length === 1 && sections[0] === "basic") {
            // Lightweight: just the basic person record
            const person = await this.pica.people.get(args.id);
            const base = { person };
            // ADR-193 Surface 2 — basic-section view still knows email,
            // but identifiers aren't fetched here so gap hints stay quiet
            // on industry-id gaps until the caller pulls identifiers.
            const gap_hints = computePersonGapHints(base);
            const enriched = gap_hints.length > 0 ? { ...base, gap_hints } : base;
            return formatSuccess("Person basic details", enriched);
        }
        // Full context call — returns person, creditedWorks, recordingCredits, agreements, identifiers
        const fullResult = await this.pica.entityContext.getPersonFull(args.id);
        const data = fullResult;
        if (allSections) {
            // Return everything
            const person = data.person || {};
            const fullName = person.name || args.id;
            const workCount = data.creditedWorks?.length || 0;
            const recordingCount = data.recordingCredits?.length || 0;
            const identifiers = data.identifiers || [];
            const idTypes = identifiers.map((i) => i.type);
            // ADR-193 Surface 2 — compute over the full payload so identifier
            // heuristics see what the entity-context call surfaced.
            const gap_hints = computePersonGapHints(data);
            const enriched = gap_hints.length > 0
                ? { ...fullResult, gap_hints }
                : fullResult;
            return formatSuccess(`"${fullName}" — credited on ${workCount} work${workCount !== 1 ? "s" : ""}, ` +
                `${recordingCount} recording${recordingCount !== 1 ? "s" : ""}. ` +
                `Identifiers: ${idTypes.length > 0 ? idTypes.join(", ") : "none"}.`, enriched);
        }
        // Build filtered response from full result
        const result = {};
        for (const section of sections) {
            try {
                switch (section) {
                    case "basic":
                        result.person = data.person;
                        break;
                    case "credits":
                        result.creditedWorks = data.creditedWorks || [];
                        break;
                    case "recording_credits":
                        result.recordingCredits = data.recordingCredits || [];
                        break;
                    case "agreements":
                        result.agreements = data.agreements || [];
                        break;
                    case "identifiers":
                        result.identifiers = data.identifiers || [];
                        break;
                    case "enrichment":
                        result.enrichment = data.enrichment || data.identifiers || [];
                        break;
                    case "connections":
                        // WS3 — the one-call org-catalog neighbourhood: works,
                        // recordings, releases, agreements as {kind,id,name,via} with
                        // per-kind counts and a capped flag.
                        result.connections = data.connections || null;
                        break;
                }
            }
            catch {
                // best-effort — the switch body is pure post-fetch property
                // access on `data` (already resolved by getPersonFull above),
                // so this catch fires only on unexpected runtime issues like
                // a malformed payload. Not a section-fetch catch — ADR-224
                // Rule 11 doesn't apply, and the lint recognises this marker.
                result[section] = { error: `Failed to load ${section}` };
            }
        }
        const person = data.person || {};
        const fullName = person.name || args.id;
        // ADR-193 Surface 2 — compute against the filtered result so hints
        // only fire on sections the caller actually requested.
        const gap_hints = computePersonGapHints(result);
        const enriched = gap_hints.length > 0 ? { ...result, gap_hints } : result;
        return formatSuccess(`"${fullName}" — sections: ${sections.join(", ")}`, enriched);
    }
    /**
     * Create person
     */
    async createPerson(args) {
        // Default roles to ["collaborator"] if not provided — the service requires at least one role
        if (!args.roles || !Array.isArray(args.roles) || args.roles.length === 0) {
            args.roles = ["collaborator"];
        }
        const person = await this.pica.people.create(args);
        const hints = [
            {
                gap: "person_not_credited",
                suggestion: "Add this person as a credit on a work (pica_credits_update)",
                severity: "important",
            },
        ];
        if (!person?.isni && !person?.musicbrainz_id) {
            hints.push({
                gap: "person_missing_identifiers",
                suggestion: "Enrich with ISNI or MusicBrainz for royalty collection (pica_resolve_person)",
                severity: "important",
            });
        }
        if (person?.email) {
            hints.push({
                gap: "person_not_invited",
                suggestion: `Invite ${person.first_name || "them"} to PICA so they can manage their own profile (pica_collaborators_invite)`,
                severity: "nice_to_have",
            });
        }
        return formatSuccess("Person created successfully", person, hints);
    }
    /**
     * Update person
     */
    async updatePerson(args) {
        const { id, ...updates } = args;
        const person = await this.pica.people.update(id, updates);
        return formatSuccess("Person updated successfully", person);
    }
    /**
     * Delete person
     */
    async deletePerson(args) {
        await this.pica.people.delete(args.id);
        return formatSuccess(`Person ${args.id} deleted successfully`);
    }
    /**
     * Give one person a new global identity (ops 482424b7). A typed refusal
     * (4xx with a SPLIT_* code) is returned as a structured error, not thrown.
     */
    async splitPersonIdentity(args) {
        const body = {};
        for (const key of [
            "move_identifiers",
            "relink_split_ids",
            "clear_person_identifiers",
            "undo_external_ids",
        ]) {
            if (args[key] !== undefined)
                body[key] = args[key];
        }
        try {
            const result = await this.pica.people.splitIdentity(args.id, body);
            return formatSuccess(`Gave person ${args.id} a new global identity`, result);
        }
        catch (err) {
            if (err instanceof ApiError &&
                [400, 404, 409, 503].includes(err.status)) {
                const bodyText = err.message.replace(/^API request failed: \d+ /, "");
                let structured = {};
                try {
                    structured = JSON.parse(bodyText);
                }
                catch {
                    // non-JSON body; fall through to the raw message
                }
                return {
                    content: [
                        {
                            type: "text",
                            text: JSON.stringify({
                                error: structured.code ?? "SPLIT_REFUSED",
                                message: structured.message ?? err.message,
                                retry_safe: false,
                                suggestion: 'read pica_people_inspect {id, sections:["global_identity"]} and pass only ids and identifier types it lists',
                            }, null, 2),
                        },
                    ],
                    isError: true,
                };
            }
            throw err;
        }
    }
    /**
     * Bulk delete people
     */
    async bulkDeletePeople(args) {
        if (args.dry_run === true) {
            const ids = Array.isArray(args.ids) ? args.ids : [];
            return formatSuccess(`Dry run — would delete ${ids.length} person(s)`, {
                dry_run: true,
                would_affect: ids,
                target_count: ids.length,
            });
        }
        await this.pica.people.bulkDelete(args.ids);
        return formatSuccess(`${args.ids.length} people deleted successfully`);
    }
    /**
     * ADR-289 Wave B2 item 4 — action-dispatched artist-projects
     * management, porting the bespoke assistant's
     * get_person_artist_projects / enrich_artist_projects /
     * set_primary_artist_project trio into a single tool.
     *
     * Whole-branch review: an unrecognized action string is now explicitly
     * rejected (mirrors the manageMultimedia guard added in projects.ts
     * Wave C1), rather than silently falling through to 'list'.
     */
    async artistProjects(args) {
        const action = args.action ?? "list";
        if (action === "enrich") {
            if (!args.person_id) {
                return {
                    content: [
                        {
                            type: "text",
                            text: "Error: person_id is required for action 'enrich'",
                        },
                    ],
                    isError: true,
                };
            }
            const result = await this.pica.people.enrichArtistProjects(args.person_id);
            // "Nothing to look up" is an answer, not an error. The server names
            // which of the two empty outcomes it was (`outcome`, from
            // EnrichmentResult in lib/services/artist-enrichment.ts) and carries
            // the next step in `message`; say that rather than "complete".
            if (result.outcome === "no_stage_names") {
                return formatSuccess(`Person ${args.person_id} has no stage names to look up yet. Add one first (pica_people_update with stage_names), then run action 'enrich' again.`, result);
            }
            if (result.outcome === "all_already_linked") {
                return formatSuccess(`Every stage name on person ${args.person_id} already has a Spotify or YouTube identity linked — nothing left to look up. Use action 'list' to see them.`, result);
            }
            return formatSuccess(`Artist project enrichment complete for person ${args.person_id}`, result);
        }
        if (action === "set_primary") {
            if (!args.project_id) {
                return {
                    content: [
                        {
                            type: "text",
                            text: "Error: project_id is required for action 'set_primary'",
                        },
                    ],
                    isError: true,
                };
            }
            const result = await this.pica.people.setPrimaryArtistProject(args.project_id);
            return formatSuccess(`Artist project ${args.project_id} set as primary`, result);
        }
        if (action === "list") {
            if (!args.person_id) {
                return {
                    content: [
                        {
                            type: "text",
                            text: "Error: person_id is required for action 'list'",
                        },
                    ],
                    isError: true,
                };
            }
            const result = await this.pica.people.listArtistProjects(args.person_id);
            return formatSuccess(`${result.total} artist project(s) for person ${args.person_id}`, result);
        }
        return {
            content: [
                {
                    type: "text",
                    text: `Error: unrecognized action '${action}'. Valid actions: list, enrich, set_primary.`,
                },
            ],
            isError: true,
        };
    }
    /**
     * ADR-289 Wave B2 item 5 — ports the bespoke assistant's
     * `triangulate_artist`. Read-only.
     */
    async triangulateArtist(args) {
        if (!args.artist_name) {
            return {
                content: [{ type: "text", text: "Error: artist_name is required" }],
                isError: true,
            };
        }
        const result = await this.pica.people.triangulate({
            artist_name: args.artist_name,
            musicbrainz_id: args.musicbrainz_id,
        });
        const confidence = result.confidence;
        const existingPerson = result.existing_person;
        const summary = existingPerson
            ? `Match found (${confidence} confidence): ${existingPerson.name} (${existingPerson.id})`
            : `No confident match (${confidence})`;
        return formatSuccess(summary, result);
    }
}
//# sourceMappingURL=people.js.map