@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
1,101 lines • 58.2 kB
JavaScript
// 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, } 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: "PRO member number: the person's account number at their own society (the PRO named in pro_name, e.g. PRS, ASCAP). Not their IPI, which is the same at every society.",
},
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 one role (e.g. artist, songwriter, producer), case-insensitive. Without `query` or `roles` it is applied server-side and `total` is the real count; combined with `query` or `roles` it is applied to the returned page and `total` comes back null (unknown).",
},
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, when browsing without `query`.",
},
tags: {
type: "array",
items: { type: "string" },
description: "Filter to people carrying ANY of these tags (array-overlap).",
},
limit: {
type: "number",
minimum: 1,
maximum: 500,
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. " +
"Carries `on_pica` and `invite_available`: true when this person " +
"joined PICA and can be invited to claim their profile (owners " +
"and admins only; null means it could not be checked). Offer to " +
"prepare the invite with pica_profile_claims_invite. → " +
"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: 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: 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: 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: 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: 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: this.artistProjects.bind(this),
},
{
definition: {
name: "pica_people_triangulate",
scope: "read:people",
audiences: ["stdio", "http", "public"],
tier: "read",
annotations: { 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;
// Always the paginated search: it forwards offset and returns the REAL
// total. The old plain-list path reported the page length as the total
// ("total: 50" for an org of 2,239 people — row-cap T3).
// A single `role` is applied server-side so the total stays real. The
// server matches exactly, and stored roles are lowercase or (rarely)
// Title-case ("Producer"), so both spellings are sent — `roles` matches
// ANY. The service applies `roles` only when there is no free-text query
// (lib/services/people-admin/search.ts), so `role` with `query` or with
// `roles` still needs a client-side pass; that total is then unknown
// rather than guessed.
const roleLower = typeof args.role === "string" ? args.role.toLowerCase() : undefined;
const roleVariants = roleLower
? [
...new Set([
roleLower,
roleLower.charAt(0).toUpperCase() + roleLower.slice(1),
]),
]
: undefined;
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: roles ?? (args.query ? undefined : roleVariants),
tags,
});
let items = result.data;
let total = result.total;
if (roleLower && (roles || args.query)) {
items = items.filter((person) => {
const personRoles = person.roles || [];
return personRoles.some((r) => r.toLowerCase() === roleLower);
});
total = null;
}
return formatStructuredList(items, "person", {
query: args.query,
total,
has_more: result.hasMore,
limit,
offset,
});
}
/**
* 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);
// ADR-329 §3: a person who has claimed their profile owns their name,
// stage names, photo and society; the route leaves those out of a
// catalogue's save and says so here. An agent never sends them as a
// suggestion; only a person in the catalogue can.
const owned = person
?.profile_owned_by_person;
if (owned) {
const notSaved = [...(owned.not_sent ?? []), ...(owned.suggested ?? [])];
return formatSuccess(`Person partly updated: ${(owned.applied ?? []).join(", ") || "nothing else"} saved. Not saved: ${notSaved.join(", ")} — this person has claimed their profile, so those details are theirs. Ask a person in the catalogue to send them the change; do not retry.`, person);
}
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