UNPKG

@withpica/mcp-server

Version:

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

617 lines • 34.1 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { elicitBulkConfirmation, formatSuccess, formatValidationError, } from "@withpica/mcp-utils"; import { WORK_WRITE_PROPERTIES } from "./works.js"; /** * Set of legal keys for the bulk `updates` object. Mirrors the * `pica_works_update` schema exactly — if it's not writable singly, * it's not writable in bulk either. */ const BULK_UPDATE_ALLOWED_KEYS = new Set(Object.keys(WORK_WRITE_PROPERTIES)); export class BulkTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_works_bulk_update", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Batch update multiple works at once. The `updates` object validates against the same " + "schema as `pica_works_update` — only real `works` columns are accepted. " + "Phantom keys (label, upc, album_name, album_art_url, album_release_date, track_number, " + "spotify_url) are rejected with the same redirect hints as the single-update tool.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { work_ids: { type: "array", items: { type: "string" }, description: "Array of work IDs to update", }, updates: { type: "object", description: "Fields to apply to every work in `work_ids`. Same schema as `pica_works_update` " + "minus the `id` field. Every key must be a real `works` column.", properties: WORK_WRITE_PROPERTIES, }, dry_run: { type: "boolean", description: "When true, returns { dry_run: true, would_affect, sample_changes } without persisting any update. Use to preview the bulk before committing.", }, }, required: ["work_ids", "updates"], }, previewMode: "dry_run", }, executor: this.bulkUpdateWorks.bind(this), }, { definition: { name: "pica_people_bulk_update_roles", scope: "write:people", audiences: ["stdio", "http", "public"], tier: "write", description: "Add or remove roles across many people in one call — action 'add' appends the given " + "roles to each person's existing list, 'remove' strips them (other roles untouched). " + "Prefer pica_people_bulk_add_tags when labelling people rather than changing their roles.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { person_ids: { type: "array", items: { type: "string" }, description: "UUIDs of people to update — from pica_people_query.", }, action: { type: "string", enum: ["add", "remove"], description: "'add' appends the given roles to each person's existing roles; 'remove' strips them.", }, roles: { type: "array", items: { type: "string" }, description: "Roles to add/remove, same vocabulary as pica_people_create's roles " + "(e.g. ['artist', 'songwriter', 'producer']).", }, dry_run: { type: "boolean", description: "When true, returns { dry_run: true, would_affect, sample_changes } without modifying any person. Preview before committing.", }, }, required: ["person_ids", "action", "roles"], }, previewMode: "dry_run", }, executor: this.bulkUpdatePeopleRoles.bind(this), }, { definition: { name: "pica_works_bulk_link_artist", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Link multiple works to the same person as their primary artist. " + "The artist_name must match one of the person's stage_names — the " + "route validates this server-side, so pass it from pica_people_inspect. " + "For a writer or producer credit rather than the primary artist, use pica_works_bulk_add_collaborator.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { work_ids: { type: "array", items: { type: "string" }, description: "Array of work IDs to link", }, person_id: { type: "string", description: "Person ID to set as primary_artist_id on every selected work", }, artist_name: { type: "string", description: "Stage name to set as primary_artist on every selected work. Must match one of the person's stage_names (route validates).", }, dry_run: { type: "boolean", description: "When true, returns { dry_run: true, would_affect, person_id, artist_name, target_count } without modifying any work. Preview before committing.", }, }, required: ["work_ids", "person_id", "artist_name"], }, previewMode: "dry_run", }, executor: this.bulkLinkArtist.bind(this), }, { definition: { name: "pica_recordings_bulk_reassign_artist", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Bulk-repoint recordings to a different primary artist in one atomic call — the fix for a junk/placeholder 'magnet' person that absorbed many recordings' primary artist link. " + "Pass exactly one selector: `recording_ids` (explicit set) or `from_person_id` (every recording currently on that person). " + "`to_person_id` is the target person (validated in your org) or null to CLEAR the link (truthful 'unknown'). The free-text artist_name label is left untouched. " + "Returns { reassigned_count, source_now_empty } — when source_now_empty is true the source person is unreferenced and can be retired. " + "For one recording, use pica_recordings_update; this one repoints many at once.", 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: { recording_ids: { type: "array", items: { type: "string" }, description: "Explicit set of recording IDs to repoint. Mutually exclusive with from_person_id.", }, from_person_id: { type: "string", description: "Repoint EVERY recording currently pointing at this person. Mutually exclusive with recording_ids.", }, to_person_id: { type: "string", description: "Target person (people.id, must be in your org). Omit or pass null to CLEAR primary_artist_id.", }, dry_run: { type: "boolean", description: "When true, returns { dry_run: true, would_affect } without writing. Preview before committing.", }, }, }, previewMode: "dry_run", }, executor: this.bulkReassignRecordingArtist.bind(this), }, { definition: { name: "pica_works_bulk_add_collaborator", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Add the same collaborator (person + role + share %) to multiple works at once. " + "Writes a publishing credit on each work. The route skips works that already " + "have this person+role pair; per-row results are returned. " + "Confirm the role normalisation by inspecting the response. " + "Prefer pica_works_bulk_copy_credits when reusing an existing work's whole collaborator set.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { work_ids: { type: "array", items: { type: "string" }, description: "Array of work IDs to add the collaborator to", }, collaborator: { type: "object", description: "Collaborator to add to every selected work. Existing person+role pairs are skipped — not overwritten.", properties: { person_id: { type: "string", description: "UUID of an existing person in the catalog", }, role: { type: "string", description: "Role (e.g. 'writer', 'composer', 'producer'). Normalised server-side; check the response for the canonical form.", }, share_percentage: { type: "number", description: "Share percentage 1–100 (the split_percentage field on work_collaborators)", }, }, required: ["person_id", "role", "share_percentage"], }, dry_run: { type: "boolean", description: "When true, returns { dry_run: true, would_affect, collaborator, target_count } without modifying any work. Preview before committing.", }, }, required: ["work_ids", "collaborator"], }, previewMode: "dry_run", }, executor: this.bulkAddCollaborator.bind(this), }, { definition: { name: "pica_work_writers_add", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Add composition WRITERS to a work by name. Each name is resolved to a person (exact match to an existing person, else a new writer person is created) and ADDITIVELY added as a writer credit — existing collaborators are never replaced. Percentages are not set here; writer shares are negotiated via attestation. Ambiguous or multi-name entries are skipped and reported.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { work_id: { type: "string", description: "The work UUID to add writers to.", }, names: { type: "array", items: { type: "string" }, description: "Writer names (free text). Each is resolved or created, then added as role='writer'.", }, }, required: ["work_id", "names"], }, }, executor: this.addWorkWriters.bind(this), }, { definition: { name: "pica_works_bulk_copy_credits", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Copy publishing collaborators from one source work to many target works at once. " + "Useful for 'apply the splits from track 3 to the rest of the album'. " + "Source work must have collaborators; targets without collaborators are filled, " + "targets with existing collaborators are handled per conflict_mode " + "(`skip` leaves them; `replace` deletes their existing collaborators first). " + "Prefer pica_works_bulk_add_collaborator when adding one person and role rather than copying a whole set.", 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: { source_work_id: { type: "string", description: "UUID of the work whose collaborators are being copied from", }, target_work_ids: { type: "array", items: { type: "string" }, description: "Works to copy the collaborators TO. The source_work_id is filtered out automatically.", }, conflict_mode: { type: "string", enum: ["skip", "replace"], description: "How to handle targets that already have collaborators. `skip`: leave them untouched. `replace`: delete their existing collaborators and copy the source's. Always preview with dry_run before `replace`.", }, dry_run: { type: "boolean", description: "When true, returns { dry_run: true, source_work_id, would_affect, conflict_mode, target_count } without modifying any work. Strongly recommended before `replace`.", }, }, required: ["source_work_id", "target_work_ids", "conflict_mode"], }, previewMode: "dry_run", }, executor: this.bulkCopyCredits.bind(this), }, { definition: { name: "pica_people_bulk_add_tags", scope: "write:people", audiences: ["stdio", "http", "public"], tier: "write", description: "Add the same tags to multiple people at once. " + "Prefer pica_people_bulk_update_roles when changing what people did rather than tagging them.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { person_ids: { type: "array", items: { type: "string" }, description: "Array of person IDs to tag", }, tags: { type: "array", items: { type: "string" }, description: "Tags to add to every selected person (existing tags are preserved; tagging is additive)", }, dry_run: { type: "boolean", description: "When true, returns { dry_run: true, would_affect, tags, target_count } without modifying any person. Preview before committing.", }, }, required: ["person_ids", "tags"], }, previewMode: "dry_run", }, executor: this.bulkAddPeopleTags.bind(this), }, ]; } async bulkUpdateWorks(args, ctx) { if (!args.work_ids?.length) { return formatValidationError({ work_ids: "must be a non-empty array" }); } if (!args.updates || Object.keys(args.updates).length === 0) { return formatValidationError({ updates: "must contain at least one field", }); } // ADR-174 Phase 2 — every key must be a real `works` column. Without // this runtime check, an agent could still call the tool with a // phantom key (e.g. upc) and the HTTP backstop would 400 per row — // wasteful and opaque. Fail fast at the tool boundary instead. const invalidKeys = Object.keys(args.updates).filter((k) => !BULK_UPDATE_ALLOWED_KEYS.has(k)); if (invalidKeys.length > 0) { return formatValidationError({ updates: `unknown field(s): ${invalidKeys.join(", ")}. Allowed fields match pica_works_update: ${[...BULK_UPDATE_ALLOWED_KEYS].sort().join(", ")}`, }); } // ADR-199 Stream 5a — dry_run preview. Validation already passed, // so the preview reflects exactly what would be sent. if (args.dry_run === true) { return formatSuccess(`Dry run — would update ${args.work_ids.length} work(s) with ${Object.keys(args.updates).length} field(s)`, { dry_run: true, would_affect: args.work_ids, sample_changes: args.updates, field_count: Object.keys(args.updates).length, target_count: args.work_ids.length, }); } // ADR-200 Phase 1 — Class D form-confirmation gate. Returns // `no_gate` for clients without elicitation support (universal // floor), or after the user accepts with confirm=true. const gate = await elicitBulkConfirmation({ server: ctx?.server, args, action: "Bulk-update works", impactSummary: `${args.work_ids.length} work(s), ${Object.keys(args.updates).length} field(s)`, warning: "This update is applied to every selected work and cannot be undone in bulk.", }); if (gate.kind === "abort") return gate.result; const result = await this.pica.bulk.updateWorks(args.work_ids, args.updates); return formatSuccess(`Bulk updated ${args.work_ids.length} works`, result); } async bulkUpdatePeopleRoles(args, ctx) { if (!args.person_ids?.length) { return formatValidationError({ person_ids: "must be a non-empty array" }); } if (!args.roles?.length) { return formatValidationError({ roles: "must be a non-empty array" }); } // ADR-199 Stream 5a — dry_run preview before role mutation. if (args.dry_run === true) { const action = args.action || "add"; return formatSuccess(`Dry run — would ${action} ${args.roles.length} role(s) on ${args.person_ids.length} person(s)`, { dry_run: true, would_affect: args.person_ids, action, roles: args.roles, target_count: args.person_ids.length, }); } // ADR-200 Phase 1 — Class D form-confirmation gate. const action = args.action || "add"; const gate = await elicitBulkConfirmation({ server: ctx?.server, args, action: `Bulk ${action} roles`, impactSummary: `${args.roles.length} role(s) across ${args.person_ids.length} person(s)`, warning: `Roles to ${action}: ${args.roles.join(", ")}.`, }); if (gate.kind === "abort") return gate.result; const result = await this.pica.bulk.updatePeopleRoles(args.person_ids, args.roles, action); return formatSuccess(`Bulk updated roles for ${args.person_ids.length} people`, result); } async bulkLinkArtist(args, ctx) { if (!args.work_ids?.length) { return formatValidationError({ work_ids: "must be a non-empty array" }); } if (!args.person_id) { return formatValidationError({ person_id: "is required" }); } if (!args.artist_name) { return formatValidationError({ artist_name: "is required" }); } if (args.dry_run === true) { // Route the preview through the SAME endpoint (and therefore the same // person-resolution + stage-name validation) the commit uses, with // dryRun=true so nothing is written. A person the commit can't find is // now refused here too — closing the old preview/commit divergence where // this branch echoed "would link N works" without validating anything. const preview = (await this.pica.bulk.linkArtist(args.work_ids, args.person_id, args.artist_name, true)); const count = typeof preview?.target_count === "number" ? preview.target_count : args.work_ids.length; return formatSuccess(`Dry run — would link ${count} work(s) to ${args.artist_name}`, (preview ?? {})); } const gate = await elicitBulkConfirmation({ server: ctx?.server, args, action: "Bulk-link artist", impactSummary: `${args.work_ids.length} work(s) → ${args.artist_name}`, warning: "Existing primary_artist on these works will be overwritten. Confirm artist_name matches a stage_name on the person.", }); if (gate.kind === "abort") return gate.result; const result = await this.pica.bulk.linkArtist(args.work_ids, args.person_id, args.artist_name); return formatSuccess(`Bulk linked ${args.work_ids.length} work(s) to ${args.artist_name}`, result); } async bulkReassignRecordingArtist(args, ctx) { const hasIds = Array.isArray(args.recording_ids) && args.recording_ids.length > 0; if (!hasIds && !args.from_person_id) { return formatValidationError({ selector: "provide recording_ids (non-empty) or from_person_id", }); } if (hasIds && args.from_person_id) { return formatValidationError({ selector: "provide either recording_ids or from_person_id, not both", }); } const toPersonId = args.to_person_id ?? null; const targetLabel = toPersonId ? `person ${toPersonId}` : "no artist (cleared)"; const sdkInput = { recordingIds: hasIds ? args.recording_ids : undefined, fromPersonId: args.from_person_id, toPersonId, }; if (args.dry_run === true) { const preview = await this.pica.bulk.reassignRecordingArtist({ ...sdkInput, dryRun: true, }); return formatSuccess("Dry run — preview of reassignment", preview); } const gate = await elicitBulkConfirmation({ server: ctx?.server, args, action: "Bulk-reassign recording artist", impactSummary: hasIds ? `${args.recording_ids.length} recording(s) → ${targetLabel}` : `all recordings on ${args.from_person_id} → ${targetLabel}`, warning: "Repoints primary_artist_id on the selected recordings. The artist_name label is unchanged. Pass to_person_id=null to clear.", }); if (gate.kind === "abort") return gate.result; const result = await this.pica.bulk.reassignRecordingArtist(sdkInput); return formatSuccess("Recordings repointed", result); } async bulkAddCollaborator(args, ctx) { if (!args.work_ids?.length) { return formatValidationError({ work_ids: "must be a non-empty array" }); } const c = args.collaborator; if (!c || typeof c !== "object") { return formatValidationError({ collaborator: "must be an object with person_id, role, share_percentage", }); } if (!c.person_id) { return formatValidationError({ "collaborator.person_id": "is required", }); } if (!c.role) { return formatValidationError({ "collaborator.role": "is required" }); } if (typeof c.share_percentage !== "number" || c.share_percentage <= 0 || c.share_percentage > 100) { return formatValidationError({ "collaborator.share_percentage": "must be a number between 1 and 100", }); } if (args.dry_run === true) { return formatSuccess(`Dry run — would add ${c.role} (${c.share_percentage}%) on ${args.work_ids.length} work(s)`, { dry_run: true, would_affect: args.work_ids, collaborator: c, target_count: args.work_ids.length, }); } const gate = await elicitBulkConfirmation({ server: ctx?.server, args, action: "Bulk-add collaborator", impactSummary: `${args.work_ids.length} work(s) — ${c.role} @ ${c.share_percentage}%`, warning: "Existing person+role pairs are skipped, not overwritten; per-row outcomes are in the response.", }); if (gate.kind === "abort") return gate.result; const result = await this.pica.bulk.addCollaborator(args.work_ids, c); return formatSuccess(`Bulk-added collaborator across ${args.work_ids.length} work(s)`, result); } async addWorkWriters(args) { const workId = String(args.work_id ?? "").trim(); const names = Array.isArray(args.names) ? args.names.filter((n) => typeof n === "string") : []; if (!workId) { return formatValidationError({ work_id: "is required" }); } if (names.length === 0) { return formatValidationError({ names: "must be a non-empty array of writer names", }); } const result = await this.pica.works.addWriters(workId, names); const writers = result.writers ?? result; return formatSuccess("Writers processed", { writers }); } async bulkCopyCredits(args, ctx) { if (!args.source_work_id) { return formatValidationError({ source_work_id: "is required" }); } if (!args.target_work_ids?.length) { return formatValidationError({ target_work_ids: "must be a non-empty array", }); } if (args.conflict_mode !== "skip" && args.conflict_mode !== "replace") { return formatValidationError({ conflict_mode: 'must be "skip" or "replace"', }); } if (args.dry_run === true) { return formatSuccess(`Dry run — would copy credits from ${args.source_work_id} to ${args.target_work_ids.length} work(s) [${args.conflict_mode}]`, { dry_run: true, source_work_id: args.source_work_id, would_affect: args.target_work_ids, conflict_mode: args.conflict_mode, target_count: args.target_work_ids.length, }); } const gate = await elicitBulkConfirmation({ server: ctx?.server, args, action: "Bulk-copy credits", impactSummary: `${args.target_work_ids.length} target work(s) [${args.conflict_mode}]`, warning: args.conflict_mode === "replace" ? "REPLACE mode: existing collaborators on target works will be DELETED before copying. This is destructive at the work_collaborators row level." : "SKIP mode: targets that already have collaborators are left untouched.", }); if (gate.kind === "abort") return gate.result; const result = await this.pica.bulk.copyCredits(args.source_work_id, args.target_work_ids, args.conflict_mode); return formatSuccess(`Bulk-copied credits from ${args.source_work_id} to ${args.target_work_ids.length} work(s)`, result); } async bulkAddPeopleTags(args, ctx) { if (!args.person_ids?.length) { return formatValidationError({ person_ids: "must be a non-empty array" }); } if (!args.tags?.length) { return formatValidationError({ tags: "must be a non-empty array" }); } if (args.dry_run === true) { return formatSuccess(`Dry run — would add ${args.tags.length} tag(s) to ${args.person_ids.length} person(s)`, { dry_run: true, would_affect: args.person_ids, tags: args.tags, target_count: args.person_ids.length, }); } const gate = await elicitBulkConfirmation({ server: ctx?.server, args, action: "Bulk add tags", impactSummary: `${args.tags.length} tag(s) across ${args.person_ids.length} person(s)`, warning: `Tags to add: ${args.tags.join(", ")}.`, }); if (gate.kind === "abort") return gate.result; const result = await this.pica.bulk.addPeopleTags(args.person_ids, args.tags); return formatSuccess(`Bulk added ${args.tags.length} tag(s) to ${args.person_ids.length} people`, result); } } //# sourceMappingURL=bulk.js.map