UNPKG

@withpica/mcp-server

Version:

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

498 lines • 28.3 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /** * Programmable rights — MCP tools (ADR-321 §9, Phase 2). * * Agents propose, humans confirm. `pica_rights_propose` follows the * duplicate-merge pattern (ADR-288): tier "destructive", approvalMode "human", * resource scope `write:catalog` so a delegated agent can PROPOSE; the org * owner approves out-of-band and `human-approval-executor` replays the frozen * params through the same `proposeRightsRule` the route calls. Proposing never * activates or confirms anything — each party confirms by email. * * `pica_rights_status` and `pica_rights_inspect` are reads over * `GET /api/admin/rights/rules/{id}` and `GET /api/admin/works/{id}/rights`. * * Phase 3: `pica_rights_statements` reads what income WOULD pay * (`/api/admin/rights-income`, `/api/admin/rights-statements/*`) and * `pica_rights_record_income` records a figure the user gave. Both need * finance access; neither moves money. */ import { ApiError } from "@withpica/mcp-sdk"; import { formatSuccess, formatValidationError } from "@withpica/mcp-utils"; import { typedError } from "./error-envelope.js"; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** The share-rule kinds the propose route accepts (programmable-rights-share-rule SHARE_RULE_KINDS). */ export const RIGHTS_SHARE_KINDS = [ "publisher", "manager_commission", "sold_share", "other", ]; /** * Build the POST body from tool args — only the fields the route reads, so * nothing unexpected is forwarded. The route re-validates everything. */ export function buildProposeBody(args) { const body = { rule: args.rule }; for (const key of [ "recording_id", "ownership", "side", "subject_person_id", "kind", "parties", "effective_from", "effective_to", ]) { if (args[key] !== undefined) body[key] = args[key]; } return body; } /** Parse the `{ error: { code, message, details } }` body off an SDK ApiError. */ function parseApiErrorBody(err) { const text = err.message.replace(/^API request failed: \d+ /, ""); try { const parsed = JSON.parse(text); return { code: parsed.error?.code, message: parsed.error?.message, reasons: parsed.error?.details?.reasons, }; } catch { return {}; } } /** The one-line message for a successful proposal. Never says "active" unless it is. */ export function proposeMessage(data) { if (data.locked) { return "The rule is locked: the only party confirmed it themselves in this one step."; } const n = data.requests?.length ?? 0; const informed = data.informed?.length ?? 0; return (`Proposed — nothing is active yet. ${n} ${n === 1 ? "party has" : "parties have"} been asked to confirm by email; ` + `the rule becomes active only when every one of them confirms.` + (informed > 0 ? ` ${informed} other ${informed === 1 ? "party was" : "parties were"} told, not asked.` : "") + " Check progress with pica_rights_status."); } /** The one-line message for a song's rights overview. Holds are PICA's rules. */ export function inspectMessage(data) { const title = data.workTitle ? `"${data.workTitle}"` : "this song"; const n = data.holds?.length ?? 0; if (n === 0) { return `Rules for ${title}. Nothing would be held under PICA's rules right now. For amounts, see pica_rights_statements.`; } return (`Rules for ${title}. ${n} ${n === 1 ? "reason" : "reasons"} money would be held under PICA's rules — ` + `each names who can clear it. These are PICA's rules, not a society's; for amounts, see pica_rights_statements.`); } /** The one-line message for statements. Never says paid. */ export function statementsMessage(entries) { return (`${entries} income ${entries === 1 ? "entry" : "entries"}: what each party WOULD receive under the locked split. ` + "No money moves and PICA's fee is shown, never charged. Say 'would receive' or 'expected', never 'paid'."); } export class RightsTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_rights_propose", // ADR-321 §9 / ADR-288 pattern — write:catalog (the propose route's // resource scope) so a catalog-scoped delegated agent can PROPOSE; // the human approval substitutes for destructive:*. scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "destructive", // Delegated-agent callers (OAuth / pica_grant_*) cannot run this // directly: the org owner approves out-of-band and PICA replays the // frozen params via human-approval-executor. approvalMode: "human", description: "Propose a programmable-rights rule on a song and email the people it affects to confirm it. " + "It PROPOSES ONLY: it never activates or confirms anything on anyone's behalf, and it never moves money. " + "Each party confirms (or declines) from their own email; the rule becomes active only when every party whose confirmation is needed has confirmed. " + 'Three kinds: `rule:"song"` — the composition rule, from the song\'s writer splits; ' + "`rule:\"master\"` with `recording_id` and `ownership` — a recording's master rule. Ask the user who owns the master first: entering a recording doesn't make them its owner. A master locks only after every owner signs its master ownership sheet; " + "`rule:\"share\"` — divides ONE party's share among people (e.g. a writer's publishing deal: writer B 8000 / publisher P 2000 of B's share; " + "or a manager's commission with an `effective_to` end date). " + "Shares are basis points: 100 = 1%, and a share rule's parties must sum to 10,000 (100% of that one party's share). " + "A refusal (song not ready, parties don't add up) writes nothing and lists the reasons. " + "For delegated agents (OAuth connectors / pica_grant_*) this tool does NOT execute directly: it creates a pending human approval and the org owner approves out-of-band — relay the returned message and poll pica_approval_status. " + "Read the song's current rules first with pica_rights_inspect; follow a proposal with pica_rights_status.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Destructive-tier proposal that emails third parties; invoked by exact name after explicit user confirmation.", inputSchema: { type: "object", properties: { work_id: { type: "string", description: "The song (work) id (UUID) — find it with pica_search_all.", }, rule: { type: "string", enum: ["song", "master", "share"], description: '"song" = the composition rule; "master" = a recording\'s master rule (needs recording_id); "share" = divide one party\'s share.', }, recording_id: { type: "string", description: 'The recording (UUID). Required for rule:"master" and for a share on side:"master"; omit for a composition share.', }, ownership: { type: "string", enum: ["only_me", "me_and_others", "someone_else"], description: 'rule:"master" only, required: the user\'s answer to "who owns the master of this recording?". Ask them — never assume. "someone_else" is refused with how to add the real owner; an agent can never lock "only_me" in one step.', }, side: { type: "string", enum: ["composition", "master"], description: 'Share rules only: which side the divided share sits on (default "composition").', }, subject_person_id: { type: "string", description: "Share rules only: the person whose share is being divided (UUID) — e.g. the writer with the publishing deal.", }, kind: { type: "string", enum: [...RIGHTS_SHARE_KINDS], description: "Share rules only: publisher (a publishing deal), manager_commission (usually with effective_to), sold_share, or other.", }, parties: { type: "array", description: "Share rules only: 1–20 people and their share_bp of the subject's share. Basis points: 100 = 1%; must sum to 10000.", items: { type: "object", properties: { person_id: { type: "string", description: "The person (UUID).", }, share_bp: { type: "integer", description: "Their part of the subject's share in basis points (100 = 1%).", }, }, required: ["person_id", "share_bp"], }, }, effective_from: { type: "string", description: "Share rules only: start date, YYYY-MM-DD.", }, effective_to: { type: "string", description: "Share rules only, optional: end date, YYYY-MM-DD (e.g. when a manager's commission ends). The share reverts after it.", }, }, required: ["work_id", "rule"], }, }, executor: this.propose.bind(this), }, { definition: { name: "pica_rights_status", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "One programmable-rights rule's state: proposed, active, superseded or withdrawn; each party and whether they have confirmed this exact version (by its fingerprint), " + "with what happened to their confirm email while it is open (sent, held, failed, none, unknown — never assume it went); any open withdrawal (disputed); and its dates. Use after pica_rights_propose to see who is still to confirm. " + "Take rule_id from pica_rights_propose's result or from pica_rights_inspect. Read-only; nothing here confirms anything.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Follow-up read keyed by a rule id from pica_rights_propose or pica_rights_inspect; the creator question routes to pica_rights_inspect.", inputSchema: { type: "object", properties: { rule_id: { type: "string", description: "The rule id (UUID) — from pica_rights_propose's ruleId or pica_rights_inspect.", }, }, required: ["rule_id"], }, }, executor: this.status.bind(this), }, { definition: { name: "pica_rights_inspect", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'who gets paid on this song?', " + "'is this song programmable?', 'who still needs to confirm the rule?', " + "'what money would PICA hold on this song, and why?'. " + "A song's programmable-rights rules on both sides — the composition and each recording's master — song rules and share rules (publishing deals, manager commissions), " + "active and proposed, with version history, disputes (open withdrawals), and HOLDS: what money would be held and why, and who or what clears it. " + "Holds name the reason and the extent (the whole payment, or one party's portion), not sums: for what income WOULD pay each party, use pica_rights_statements. " + "HONESTY: holds are PICA's own rules about when money is released, not a society's (PRS, MLC, PPL…) — say so; for what may be holding up society income use pica_royalties_blockers. " + "PAYOUT READINESS (payout_readiness): for the composition and each master with a locked split, whether every party it pays can be paid — per party: ready, onboarding or restricted (still setting up with Stripe; requirementsDue is Stripe's own list), not_started, needs_invite, invited, no_email (can't be invited), or conflict (two accounts claim one person; flagged, never guessed). " + "It is finance data: null without the organisation's finance access; a side whose read failed has readiness null, which means unknown, never 'nobody to pay'. PICA never holds bank details. " + "Sending a co-writer a payout-setup link is done by a signed-in owner or admin on the song page, not by an agent. " + "To propose a rule use pica_rights_propose.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { work_id: { type: "string", description: "The song (work) id (UUID) — find it with pica_search_all.", }, }, required: ["work_id"], }, }, executor: this.inspect.bind(this), }, { definition: { name: "pica_rights_statements", scope: "read:finances", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'what would I get from this sync fee?', 'who gets what from this money?', " + "'does my PRS statement match my split?', 'is my registration right?'. " + "Statements — COMPUTED, NOT PAID: no money moves and nothing is charged. " + "With no arguments: direct income recorded on the catalogue (newest first), each divided by the song's locked split in force on the date it was EARNED, " + "with each party's line in the money's own currency, PICA's proposed fee as its own line (shown, never charged), and every hold with why, who can clear it and since when; " + "plus the four measures. With `income_id`: one income event, recomputed now. " + "With `work_id`: that song's income AND its imported society statements checked against the split — for writer societies (PRS, MLC, ASCAP…) each co-writer's EXPECTED amount and whether the registration matches the split; " + "for PPL / SoundExchange only whether the performer line-up is complete (no expected amount is computed for performer money). " + "HONESTY: say 'would receive' / 'expected', never 'paid' or 'received'. Society figures are what the statement shows, not the society's own reasoning. " + "Needs finance access. To add income use pica_rights_record_income.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { work_id: { type: "string", description: "Optional song (work) id (UUID) — one song's income and society statement checks. Find it with pica_search_all.", }, income_id: { type: "string", description: "Optional income event id (UUID) from an earlier statement — that one entry, recomputed now.", }, before: { type: "string", description: "Optional page cursor: the `next` value from the previous page.", }, }, }, }, executor: this.statements.bind(this), }, { definition: { name: "pica_rights_record_income", scope: "write:finances", audiences: ["stdio", "http", "public"], tier: "write", description: "Record direct income on a song so PICA can show what each party WOULD receive under its locked split. " + "NO MONEY MOVES and nothing is charged — this records a figure the user gave you, labelled 'entered by your agent'. " + "Only record an amount the user has told you or confirmed; never estimate one. " + "Either `enquiry_id` (a licensing enquiry the user has quoted or accepted — its quoted fee is used) " + 'or `work_id` + `income_type` + `fee_kind` + `currency` + `amount` (a decimal string, e.g. "1250.00") + `earned_on`. ' + "`earned_on` picks the split that was in force then. `side` is composition (default) or master (then `recording_id`). " + "The result is the statement: each party's line, the fee shown, and anything held with why; read it again later with pica_rights_statements. Needs finance access.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Records a user-supplied figure; the creator question 'what would I get?' routes to pica_rights_statements.", inputSchema: { type: "object", properties: { enquiry_id: { type: "string", description: "A licensing enquiry id (UUID) the user quoted or accepted — its quoted fee becomes the income.", }, work_id: { type: "string", description: "The song (work) id (UUID).", }, side: { type: "string", enum: ["composition", "master"], description: "composition (the song, default) or master (a recording — give recording_id).", }, recording_id: { type: "string", description: "The recording id (UUID) for master income.", }, income_type: { type: "string", enum: [ "sync_fee", "direct_licence_fee", "commission", "live_fee", "other", ], description: "What the money is.", }, fee_kind: { type: "string", enum: ["licence", "no_buyer"], description: "licence = a buyer paid it (PICA's proposed fee is on top, paid by the buyer); no_buyer = band income, a commission… (the fee would come off the top). Either way it is shown, never charged.", }, currency: { type: "string", description: "ISO code of the currency the money is actually in, e.g. GBP.", }, amount: { type: "string", description: 'Decimal string in that currency, e.g. "1250.00" — never a guess.', }, earned_on: { type: "string", description: "YYYY-MM-DD — when the money was earned.", }, description: { type: "string", description: "Optional short note — a sentence or two.", }, }, required: ["earned_on"], }, }, executor: this.recordIncome.bind(this), }, ]; } async propose(args) { const workId = args.work_id; if (typeof workId !== "string" || !UUID.test(workId)) { return formatValidationError({ work_id: "must be the song's id (a UUID) — find it with pica_search_all", }); } if (args.rule !== "song" && args.rule !== "master" && args.rule !== "share") { return formatValidationError({ rule: 'must be "song", "master" or "share"', }); } try { const data = await this.pica.rights.propose(workId, buildProposeBody(args)); return formatSuccess(proposeMessage(data), data); } catch (err) { // A 422 is a refusal decided before anything was written — surface the // reasons so the agent can tell the user what to fix. if (err instanceof ApiError && (err.status === 422 || err.status === 400)) { const body = parseApiErrorBody(err); const code = err.status === 400 ? "VALIDATION_ERROR" : body.code === "NOT_CHECKED" ? "RIGHTS_NOT_CHECKED" : "RIGHTS_NOT_READY"; return typedError(code, body.message ?? err.message, { reasons: Array.isArray(body.reasons) ? body.reasons : [], nothing_written: true, retry_safe: false, }); } throw err; } } async status(args) { const ruleId = args.rule_id; if (typeof ruleId !== "string" || !UUID.test(ruleId)) { return formatValidationError({ rule_id: "must be a rule id (a UUID) — pica_rights_inspect lists a song's rules", }); } const data = await this.pica.rights.status(ruleId); return formatSuccess("Rule status", data); } async statements(args) { const workId = args.work_id; const incomeId = args.income_id; if (workId !== undefined && (typeof workId !== "string" || !UUID.test(workId))) { return formatValidationError({ work_id: "must be the song's id (a UUID) — find it with pica_search_all", }); } if (incomeId !== undefined && (typeof incomeId !== "string" || !UUID.test(incomeId))) { return formatValidationError({ income_id: "must be an income id (a UUID) from an earlier statement", }); } if (typeof incomeId === "string") { const data = await this.pica.rights.statement(incomeId); return formatSuccess(statementsMessage(1), data); } const [page, measures, society] = await Promise.all([ this.pica.rights.statements({ workId: typeof workId === "string" ? workId : undefined, before: typeof args.before === "string" ? args.before : undefined, }), this.pica.rights.measures(), typeof workId === "string" ? this.pica.rights.societyChecks(workId) : Promise.resolve(undefined), ]); const n = page?.entries?.length ?? 0; return formatSuccess(statementsMessage(n), { ...page, measures, ...(society !== undefined ? { society } : {}), }); } async recordIncome(args) { const body = {}; for (const key of [ "enquiry_id", "work_id", "side", "recording_id", "income_type", "fee_kind", "currency", "amount", "earned_on", "description", ]) { if (args[key] !== undefined) body[key] = args[key]; } if (typeof body.amount === "number") { return formatValidationError({ amount: 'must be a decimal string, e.g. "1250.00" — not a number', }); } try { const data = await this.pica.rights.recordIncome(body); return formatSuccess("Income recorded. What each party WOULD receive is shown — no money moves and no fee is charged.", data); } catch (err) { if (err instanceof ApiError && (err.status === 400 || err.status === 404 || err.status === 409)) { const parsed = parseApiErrorBody(err); return typedError(parsed.code ?? "VALIDATION_ERROR", parsed.message ?? err.message, { nothing_written: true, retry_safe: false }); } throw err; } } async inspect(args) { const workId = args.work_id; if (typeof workId !== "string" || !UUID.test(workId)) { return formatValidationError({ work_id: "must be the song's id (a UUID) — find it with pica_search_all", }); } const data = await this.pica.rights.inspect(workId); return formatSuccess(inspectMessage(data), data); } } //# sourceMappingURL=rights.js.map