@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
350 lines • 18.5 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { formatSuccess, withBillingGate, } from "@withpica/mcp-utils";
export class CustodyTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
return [
{
definition: {
name: "pica_custody_claim",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Request custody of a work's composition or master chain. " +
"If another org currently holds custody, creates a pending claim; the current " +
"custodian has 7 days to accept, decline, or dispute before the claim auto-approves. " +
"If you already hold custody, this errors (CUSTODY_ALREADY_HELD) — a song you entered " +
"yourself is already in your custody, so there is nothing to claim. To record who owns " +
"a recording's master (e.g. the artist at 100%), use pica_recording_custody_assign. " +
"Admin/owner access required. " +
"Prefer pica_custody_reconcile to claim many works at once where " +
"your catalog is already the named custodian.",
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: "UUID of the work to claim.",
},
chain: {
type: "string",
enum: ["composition", "master"],
description: "'composition' = publishing rights; 'master' = recording rights.",
},
reason: {
type: "string",
description: "Optional freetext shown to the current custodian in the notification.",
},
},
required: ["work_id", "chain"],
},
annotations: {
title: "claim custody",
destructiveHint: false,
idempotentHint: false,
},
},
executor: withBillingGate(this.pica, "custody claim", this.claim.bind(this)),
},
{
definition: {
name: "pica_custody_respond",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Respond to an incoming custody claim. Actions: 'accept' transfers custody " +
"to the claimant; 'decline' rejects the claim (custody unchanged); 'dispute' " +
"freezes the claim for human review. reason is required for dispute. Only the " +
"current custodian can accept or decline; the custodian OR any credited party " +
"can dispute. Admin/owner access required. Use pica_custody_list " +
"to find the claim_id of an incoming claim first.",
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: {
claim_id: {
type: "string",
description: "UUID of the custody claim to respond to.",
},
action: {
type: "string",
enum: ["accept", "decline", "dispute"],
description: "How to respond to the claim",
},
reason: {
type: "string",
description: "Required when action='dispute'. Optional for decline.",
},
},
required: ["claim_id", "action"],
},
annotations: {
title: "respond to a custody claim",
destructiveHint: false,
idempotentHint: false,
},
},
executor: withBillingGate(this.pica, "custody response", this.respond.bind(this)),
},
{
definition: {
name: "pica_custody_list",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Use when the user asks: 'who has custody of X right now?', 'who owns the asset?'. " +
"List custody claims for your catalog — both incoming " +
"(claims others filed against your custody) and outgoing (claims you filed). " +
"Defaults to pending claims only. Available to any authenticated org member. " +
"For the record of past custody changes on one work, use " +
"pica_custody_history; this one shows open claims.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
direction: {
type: "string",
enum: ["incoming", "outgoing", "both"],
description: "Default: both.",
},
status: {
// ⚠️ COPIED, NOT IMPORTED — and nothing detects a drift.
// The seven live values are the `ClaimStatus` union in
// lib/services/custody-claim.ts (ADR-158 Plan B migrations
// 02/03); `all` is this filter's own escape hatch, not a
// stored status. mcp-server cannot import that module: it sits
// outside `rootDir: ./src`, and it reaches the DB client, the
// Telegram integration and the Slack types through `@/`
// aliases this package does not map. So the list is restated
// here, and `filter-param-enums.test.ts` pins only that THIS
// schema still declares these eight strings — it compares a
// literal to a literal and cannot see a change made on the
// service side. Before A10 (2026-08-24) the same values sat in
// the description as prose, equally unchecked; the enum at
// least makes an invalid value a schema error at the tool
// surface. If ClaimStatus gains a state, change it here too.
type: "string",
enum: [
"pending",
"accepted",
"declined",
"disputed",
"auto_approved",
"withdrawn",
"expired",
"all",
],
description: "Filter by claim status, or `all` for every status. Default: pending.",
},
limit: {
type: "number",
description: "1..200. Default 50.",
},
},
},
annotations: {
title: "list custody claims",
readOnlyHint: true,
idempotentHint: true,
},
},
executor: this.list.bind(this),
},
{
definition: {
name: "pica_custody_history",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Return the custody events in a work's provenance history. Every entry " +
"includes an attestation_hash so the history is cryptographically anchored. " +
"Use this to audit who changed custody when. Available to any authenticated org member. " +
"For claims still awaiting a decision, use pica_custody_list; " +
"this one is the settled record.",
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: "UUID of the work whose history to fetch.",
},
change_type: {
type: "string",
description: "Filter to one change_type (custody_claimed|custody_transferred|custody_released|custody_expired|custody_transfer_auto) or 'all'. Default 'all'.",
},
limit: {
type: "number",
description: "1..500. Default 100.",
},
},
required: ["work_id"],
},
annotations: {
title: "custody history",
readOnlyHint: true,
idempotentHint: true,
},
},
executor: this.history.bind(this),
},
{
definition: {
name: "pica_custody_resolve_pending",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Preview works where this org (or a specific global_publisher_id) is named " +
"as canonical custodian via work_custodians.custodian_global_publisher_id but " +
"has not yet been claimed via a PICA org membership. The 'Warner Chappell signs " +
"up and finds 38 works waiting' view. Read-only — use pica_custody_reconcile " +
"to actually claim them. Admin/owner access required.",
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: {
global_publisher_id: {
type: "string",
description: "Optional. If omitted, uses the caller org's own global_publisher_id bridge.",
},
},
},
annotations: {
title: "resolve pending custody",
readOnlyHint: true,
idempotentHint: true,
},
},
executor: withBillingGate(this.pica, "custody resolve", this.resolvePending.bind(this)),
},
{
definition: {
name: "pica_custody_reconcile",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Bulk-claim every work where your catalog (or a specific global_publisher_id) " +
"is named as canonical custodian but hasn't been locally claimed yet. Files " +
"one claim per matching work. Default is dry-run — pass confirm=true " +
"to execute. Admin/owner access required. Preview the same set " +
"read-only with pica_custody_resolve_pending first.",
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: {
global_publisher_id: {
type: "string",
description: "UUID of the global publisher to reconcile claims for. If omitted, reconciles for all publishers linked to your catalog.",
},
confirm: {
type: "boolean",
description: "Required to actually write claims. Default false = dry-run.",
},
},
},
annotations: {
title: "reconcile custody",
destructiveHint: false,
idempotentHint: false,
},
},
executor: withBillingGate(this.pica, "custody reconcile", this.reconcile.bind(this)),
},
];
}
async claim(args) {
const data = await this.pica.custody.claim({
work_id: args.work_id,
chain: args.chain,
reason: args.reason,
});
return formatSuccess(`Custody claim created (${args.chain} chain). The current custodian has 7 days to respond before the claim auto-approves.`, data);
}
async respond(args) {
const data = await this.pica.custody.respond({
claim_id: args.claim_id,
action: args.action,
reason: args.reason,
});
const verb = args.action === "accept"
? "accepted"
: args.action === "decline"
? "declined"
: "disputed";
return formatSuccess(`Custody claim ${verb}.`, data);
}
async list(args) {
const data = await this.pica.custody.list({
direction: args.direction,
status: args.status,
limit: args.limit,
});
const incoming = data?.incoming ?? [];
const outgoing = data?.outgoing ?? [];
const hints = [];
if (incoming.length > 0) {
hints.push({
gap: "custody_claims_incoming",
suggestion: `You have ${incoming.length} incoming custody claim(s) — respond via pica_custody_respond before the 7-day window closes.`,
count: incoming.length,
severity: "important",
});
}
return formatSuccess(`Found ${incoming.length} incoming + ${outgoing.length} outgoing custody claim(s).`, data, hints);
}
async history(args) {
const data = await this.pica.custody.history({
work_id: args.work_id,
change_type: args.change_type,
limit: args.limit,
});
const count = data?.count ?? 0;
return formatSuccess(count > 0
? `Returned ${count} custody history row(s) for the work.`
: "No custody history rows for this work.", data);
}
async resolvePending(args) {
const data = await this.pica.custody.resolvePending({
global_publisher_id: args.global_publisher_id,
});
const count = data?.count ?? 0;
const hints = [];
if (count > 0) {
hints.push({
gap: "custody_inheritable",
suggestion: `${count} work(s) reference your publisher identity but haven't been claimed — run pica_custody_reconcile with confirm=true to claim them in bulk.`,
count,
severity: "important",
});
}
return formatSuccess(count > 0
? `${count} inheritable work(s) found.`
: "No inheritable works found.", data, hints);
}
async reconcile(args) {
const data = await this.pica.custody.reconcile({
global_publisher_id: args.global_publisher_id,
confirm: args.confirm === true,
});
if (data?.dry_run) {
return formatSuccess(`Dry run — ${data.would_claim ?? 0} claim(s) would be created. Pass confirm=true to execute.`, data);
}
return formatSuccess(`Reconciliation complete — ${data?.claimed ?? 0} claimed, ${data?.failed ?? 0} failed.`, data);
}
}
//# sourceMappingURL=custody.js.map