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