UNPKG

@withpica/mcp-server

Version:

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

462 lines • 24.6 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { formatSuccess } from "@withpica/mcp-utils"; import { typedError } from "./error-envelope.js"; export class RoyaltiesTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_royalties_list", scope: "read:finances", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'what royalties has X paid me?', 'earnings from PRS', 'MLC payouts', 'royalty entries from X'. " + "List royalty payment records with optional filters. " + "Returns individual payment entries from imported statements. " + "For totals and a breakdown by source rather than individual entries, use pica_royalties_get.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { source: { type: "string", description: "Filter by payment source: prs, ppl, mcps, ascap, bmi, sesac, " + "spotify, apple_music, youtube, distrokid, tunecore, cdbaby, etc.", }, work_id: { type: "string", description: "Filter by work ID (UUID)", }, from_date: { type: "string", description: "Filter payments from date (ISO format YYYY-MM-DD)", }, to_date: { type: "string", description: "Filter payments to date (ISO format YYYY-MM-DD)", }, limit: { type: "number", minimum: 1, maximum: 100, description: "Number of results (default 50, max 100)", }, offset: { type: "number", description: "Pagination offset (default 0)", }, }, }, }, executor: this.listPayments.bind(this), }, { definition: { name: "pica_royalties_get", scope: "read:finances", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'how much did I earn?', 'what are my earnings?', 'did I get paid?', " + "'how much has this song earned?', 'what's X earned?', 'what's this song worth?'. " + "Detailed earnings summary with breakdown by source and tax category, plus recent payment records and pagination. " + "For what's holding a song's money up rather than what's paid (missing ISWC or IPI, no confirmed registration) use pica_royalties_blockers instead.", 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: { start_date: { type: "string", description: "Filter from date (ISO format YYYY-MM-DD)", }, end_date: { type: "string", description: "Filter to date (ISO format YYYY-MM-DD)", }, source: { type: "string", description: "Filter by source (prs, ppl, spotify, etc.)", }, limit: { type: "number", minimum: 1, maximum: 100, description: "Number of recent payments to return (default 50)", }, }, }, }, executor: this.getEarnings.bind(this), }, // Tier 2 (MCP tool-surface consolidation, ADR-149 query/inspect // pattern): pica_statements_query/_inspect are the canonical read // pair, mirroring pica_releases_query/_inspect. (pica_statements_get // was removed in the 2.93.0 sweep and pica_statements_list in 2.99.0.) { definition: { name: "pica_statements_query", scope: "read:finances", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'show me my statements', 'list royalty statements', 'what statements are imported?'. " + "List past statement imports — shows which PRO/NRO statements " + "have been imported and when. Supported sources: PRS, PPL, MCPS, " + "ASCAP, BMI, SESAC, SoundExchange, SOCAN, APRA. " + "For one batch's line items, use pica_statements_inspect; this one lists the imports.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { source: { type: "string", description: "Filter by source (prs, ppl, ascap, etc.)", }, date_from: { type: "string", description: "Filter imports after this date (ISO format)", }, date_to: { type: "string", description: "Filter imports before this date (ISO format)", }, search: { type: "string", description: "Search by filename", }, limit: { type: "number", minimum: 1, maximum: 50, description: "Number of results (default 20, max 50)", }, offset: { type: "number", description: "Pagination offset", }, sort: { type: "string", description: "Sort order: newest (default) or oldest", }, }, }, }, executor: this.listStatements.bind(this), }, { definition: { name: "pica_statements_inspect", scope: "read:finances", audiences: ["stdio", "http", "public"], tier: "read", description: "Get details of a specific statement import batch, including " + "line items with per-work/territory breakdowns. " + "Find the batch id first with pica_statements_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.", // id/statement_id/batch_id are interchangeable entry points — see // pica_recordings_inspect. batch_id is the legacy field name from // pica_statements_get (kept as a fallback so existing callers // don't break — the shared getStatement executor still reads it). // required:[] + requiredOneOf lets pica_execute still hard-reject // empty/stripped args while accepting any of the three. requiredOneOf: [["id", "statement_id", "batch_id"]], inputSchema: { type: "object", properties: { id: { type: "string", description: "The import batch ID (UUID)", }, statement_id: { type: "string", description: "The import batch ID (UUID) — alias for id.", }, batch_id: { type: "string", description: "The import batch ID (UUID). Legacy alias for id/statement_id.", }, limit: { type: "number", minimum: 1, maximum: 100, description: "Number of line items to return (default 50, max 100)", }, offset: { type: "number", description: "Pagination offset for line items", }, }, required: [], }, }, executor: this.getStatement.bind(this), }, // UNVERIFIED (ADR-286 Task 8b): authored in a worktree that cannot build // these packages; must compile + test in the main checkout before merge. { definition: { name: "pica_royalties_unmatched", scope: "read:finances", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'which royalty lines could not be matched?', " + "'show me unmatched payments', 'what royalties are unattributed?'. " + "List royalty payment lines that could not be automatically attributed " + "to a work or recording during import, together with fuzzy-match candidates. " + "IMPORTANT: present the candidates to the user and ask them to confirm a " + "match before calling pica_royalties_attribute. Never auto-apply a fuzzy " + "candidate without explicit user confirmation.", 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: { import_batch_id: { type: "string", description: "Limit results to a specific import batch (UUID). " + "Omit to return all unmatched lines across all batches.", }, }, }, }, executor: this.listUnmatched.bind(this), }, { definition: { name: "pica_royalties_blockers", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'why isn't this song being paid?', " + "'what's holding up my money?', 'why am I not getting paid for X?', " + "'what's stopping my songs being paid?'. " + "What in the record is likely holding up a song's society income (PRS, MLC, PPL and so on), " + "each with the fix: missing ISWC, writers without an IPI or society membership, shares that " + "don't total 100%, disputed credits, no confirmed registration, recordings without an ISRC. " + "Pass work_id for one song; omit it for the whole catalogue (a summary plus the songs that aren't clear, paged). " + "HONESTY RULES — follow them exactly: (1) say 'nothing is holding it up' ONLY when state is 'clear'; " + "if state is 'unknown' or `unchecked` is non-empty, tell the user those areas could not be checked — never call it clear. " + "(2) these are what the record shows, not the society's own reason; say so. " + "(3) evidence 'record_shows' means not confirmed — e.g. 'no confirmed registration', never 'not registered'. " + "For earnings amounts use pica_royalties_get.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { work_id: { type: "string", format: "uuid", description: "The song (work) to check — its id (UUID), not its title. Find the id with pica_search_all. Omit for the whole catalogue.", }, code: { type: "string", enum: [ "no_iswc", "no_writers", "writer_shares_unknown", "writer_shares_not_100", "writer_person_deleted", "writer_no_ipi", "writer_no_society", "credit_disputed", "split_dispute_open", "not_registered", "recording_no_isrc", ], description: "Catalogue only: list just the songs with this blocker.", }, limit: { type: "number", minimum: 1, maximum: 200, description: "Catalogue only: songs per page, 1–200 (default 50).", }, offset: { type: "number", description: "Catalogue only: songs to skip (default 0).", }, }, }, }, executor: this.getBlockers.bind(this), }, // UNVERIFIED (ADR-286 Task 8b): authored in a worktree that cannot build // these packages; must compile + test in the main checkout before merge. { definition: { name: "pica_royalties_attribute", scope: "write:finances", audiences: ["stdio", "http", "public"], tier: "write", description: "Attribute an unmatched royalty payment line to a specific work or recording. " + "Provide work_id OR recording_id (they are mutually exclusive). " + "Omit both to un-attribute a previously attributed line. " + "IMPORTANT: Only apply a work_id/recording_id the user has explicitly confirmed. " + "Never auto-apply a fuzzy candidate from pica_royalties_unmatched — " + "surface candidates and let the user choose.", 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: { royalty_payment_id: { type: "string", description: "The royalty payment line ID to attribute (UUID).", }, work_id: { type: "string", description: "Work to attribute this payment to (UUID). " + "Mutually exclusive with recording_id. " + "Omit both work_id and recording_id to un-attribute.", }, recording_id: { type: "string", description: "Recording to attribute this payment to (UUID). " + "Mutually exclusive with work_id. " + "Omit both work_id and recording_id to un-attribute.", }, }, required: ["royalty_payment_id"], }, }, executor: this.attributePayment.bind(this), }, ]; } async listPayments(args) { const data = await this.pica.royalties.payments({ source: args.source, workId: args.work_id, fromDate: args.from_date, toDate: args.to_date, limit: args.limit || 50, offset: args.offset || 0, }); const hints = []; if (!data?.length) { hints.push({ gap: "no_payments", suggestion: "Import a royalty statement to start tracking earnings (upload a PRS/PPL/ASCAP CSV)", severity: "important", }); } return formatSuccess(`Found ${data?.length || 0} payment records`, data, hints); } async getEarnings(args) { const data = await this.pica.royalties.earnings({ start_date: args.start_date, end_date: args.end_date, source: args.source, limit: args.limit || 50, }); return formatSuccess("Earnings summary", data); } async getBlockers(args) { if (args.work_id) { const data = await this.pica.royalties.blockers(String(args.work_id)); return formatSuccess(songBlockersMessage(data), data); } const data = await this.pica.royalties.blockersCatalogue({ code: args.code, limit: args.limit, offset: args.offset, }); return formatSuccess(catalogueBlockersMessage(data), data); } async listStatements(args) { const data = await this.pica.statements.history({ source: args.source, dateFrom: args.date_from, dateTo: args.date_to, search: args.search, limit: args.limit || 20, offset: args.offset || 0, sort: args.sort, }); return formatSuccess(`Found ${data?.imports?.length || data?.total || 0} statement imports`, data); } async getStatement(args) { const _id = args.id ?? args.statement_id ?? args.batch_id; if (!_id) { // typedError so the refusal carries isError + a machine code (see // ./error-envelope.ts); formatAsText({error}) reads as a success. return typedError("VALIDATION_ERROR", "batch_id is required"); } const data = await this.pica.statements.get(_id); return formatSuccess(`Statement batch: ${data?.sourceName || data?.source || "unknown"} — ${data?.lineItemCount || 0} line items`, data); } // UNVERIFIED (ADR-286 Task 8b): authored in a worktree that cannot build // these packages; must compile + test in the main checkout before merge. async listUnmatched(args) { const data = await this.pica.royalties.unmatched({ importBatchId: args.import_batch_id, }); const items = Array.isArray(data?.items) ? data.items : []; const hints = [ { gap: "unmatched_lines_require_confirmation", suggestion: "Present the candidate works/recordings shown above to the user and " + "ask them to confirm which match is correct before calling " + "pica_royalties_attribute. Never auto-apply a fuzzy suggestion.", severity: "important", }, ]; return formatSuccess(`Found ${items.length} unmatched royalty line${items.length === 1 ? "" : "s"}`, data, items.length > 0 ? hints : []); } // UNVERIFIED (ADR-286 Task 8b): authored in a worktree that cannot build // these packages; must compile + test in the main checkout before merge. async attributePayment(args) { if (!args.royalty_payment_id) { return typedError("VALIDATION_ERROR", "royalty_payment_id is required"); } const data = await this.pica.royalties.attribute({ royaltyPaymentId: args.royalty_payment_id, workId: args.work_id, recordingId: args.recording_id, }); const action = args.work_id || args.recording_id ? "attributed" : "un-attributed"; return formatSuccess(`Royalty payment ${args.royalty_payment_id} ${action}`, data); } } /** * The one-line message for a song's blockers. It never calls a song clear * unless `state` is "clear" — the honesty rule in the tool description, made * true in the text an agent reads first. */ export function songBlockersMessage(result) { const n = result.blockers?.length ?? 0; const gaps = (result.unchecked ?? []) .map((u) => u.reason) .filter(Boolean) .join("; "); if (result.state === "clear") return "Nothing in the record looks like it's holding up money on this song."; if (n > 0) return (`${n} thing${n === 1 ? "" : "s"} in the record likely holding up money on this song (what the record shows, not the society's own reason).` + (gaps ? ` Also could not check: ${gaps}.` : "")); return `Nothing found so far, but this is NOT a clean bill — could not check: ${gaps || "some areas"}.`; } /** The one-line message for the catalogue roll-up. Same honesty rule. */ export function catalogueBlockersMessage(data) { const s = data.summary ?? {}; const base = `${s.total ?? 0} songs: ${s.blocked ?? 0} with something likely holding up money, ${s.clear ?? 0} clear`; const unknown = s.unknown ? `, ${s.unknown} not fully checked (not clear — say so)` : ""; const low = s.unchecked?.length ? " Some areas could not be checked, so counts may be low." : ""; return `${base}${unknown}.${low}`; } //# sourceMappingURL=royalties.js.map