@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
617 lines • 34.1 kB
JavaScript
// 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