UNPKG

@withpica/mcp-server

Version:

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

534 lines • 36 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { formatAsText, formatSuccess, formatValidationError, } from "@withpica/mcp-utils"; import { withBillingGate } from "@withpica/mcp-utils"; export class ExportTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_export_catalog_csv", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'export this work to PRO', 'register with my distributor', 'generate a PPL submission', 'sync one-sheet for this song'. " + "Export a single work — or a batch of works — to an industry submission format. " + "`format` selects the destination: `pro` (CWR-style writer/publisher registration for PRS, ASCAP, BMI, GEMA), " + "`distributor` (DSP delivery for DistroKid, TuneCore, CD Baby), " + "`nro` (neighbouring rights for PPL, SoundExchange), " + "or `sync-pdf` (single-work sync one-sheet PDF). " + "Pass exactly one of `work_id` (single export — required for `sync-pdf`) or `work_ids` (bulk export, comma-separated UUIDs — not supported for `sync-pdf`). " + "For `pro`/`distributor`/`nro`, returns the generated CSV as inline text ({format, csv}) — there is no download URL, so present it directly " + "(e.g. as a code block) or write it to a file; `pica_download` needs a presigned URL and does not apply here. " + "`sync-pdf` returns structured one-sheet DATA — the rendered one-page PDF exists but is a browser download at " + "`/api/admin/works/export?format=sync-pdf&as=pdf&workId=<id>`, which streams raw PDF bytes this transport cannot carry; " + "hand the user that URL, or direct them to https://withpica.com/inspect. Do NOT pass `as` to this tool — it is not an input here. " + "Direct the user to https://withpica.com/inspect for the full export UI. " + "For a registration submission the user hands to a PRO/CMO — a ZIP with writer " + "and writer-summary CSVs plus a CWR field mapping guide — use " + "pica_export_song_registration instead; this tool returns one flat CSV.", workflows: ["export-required"], vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { format: { type: "string", enum: ["pro", "distributor", "nro", "sync-pdf"], description: "Export destination format: pro (PRO/CMO registration), distributor (DSP delivery), nro (neighbouring rights), sync-pdf (single-work sync one-sheet).", }, work_id: { type: "string", description: "UUID of a single work to export. Mutually exclusive with work_ids; one of the two is required. Required for sync-pdf format.", }, work_ids: { type: "array", items: { type: "string" }, description: "Array of work UUIDs for bulk export. Mutually exclusive with work_id; one of the two is required. Not supported for sync-pdf format.", }, }, // NB: the backend ALSO demands exactly one of work_id / work_ids — // a bare {format} call 400s. JSON-Schema `anyOf` isn't expressible // in this tool's inputSchema type (and isn't honoured uniformly by // MCP clients), so the requirement is enforced at runtime in the // executor below and spelled out in the description (feedback // 2020c81a). required: ["format"], }, }, executor: this.exportCatalogCsv.bind(this), }, { definition: { name: "pica_export_song_registration", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Export works in song registration format for PRO/CMO registration. " + "This is NOT a CWR file — the ZIP holds CSVs (works, writers, writer summary), JSON, a PDF summary " + "and `mapping-guide.md`, which maps the CSV columns onto CWR fields for whoever does the submission. " + "PICA has no CWR emitter; never tell the user you produced a CWR file. " + "Returns a signed-URL JSON envelope ({ download_url, expires_at, file_size_bytes }) pointing to the generated ZIP. " + "Filter by ISWC status: pass iswc_status='missing' to export only works that still need an ISWC " + "(the registration-priority bucket), 'present' for works that already have one, or 'all' (default) " + "for the entire catalog. " + "Pass work_ids to scope to a specific set of works (UUIDs); omit for the full catalog. " + "For a single flat CSV in a named destination format (pro / distributor / nro) " + "rather than this multi-file bundle, use pica_export_catalog_csv instead.", workflows: ["export-required"], 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: { iswc_status: { type: "string", enum: ["missing", "present", "all"], description: "Filter works by ISWC assignment. 'missing' = works without an ISWC (the typical PRO-registration target), 'present' = works that already have an ISWC, 'all' = no filter (default).", }, work_ids: { type: "array", items: { type: "string" }, description: "Optional list of work UUIDs to scope the export to. Omit to export the full catalog.", }, }, }, }, executor: withBillingGate(this.pica, "song registration export", this.exportSongRegistration.bind(this)), }, { definition: { name: "pica_export_industry_ready", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Use when the user asks: 'export an industry-ready package', 'get a registration-ready bundle', 'export for registration'. " + "Export industry-ready metadata package — formatted for distribution to publishers, labels, and sync agents. " + "Returns a signed-URL JSON envelope ({ download_url, expires_at, file_size_bytes }) pointing to the generated ZIP. " + "Set inline:true to instead receive the structured export DATA inline (no S3 URL) — use this when running in a sandbox whose allowlist can't reach the S3 download host. " + "Pass work_ids to scope to a specific set of works (UUIDs); omit for the full catalog. " + "This is the general-purpose hand-off bundle; when the destination is specifically " + "a PRO/CMO registration, prefer pica_export_song_registration, which adds the " + "writer-summary CSV and CWR field mapping a registrar needs.", workflows: ["export-required"], vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { work_ids: { type: "array", items: { type: "string" }, description: "Optional list of work UUIDs to scope the export to. Omit to export the full catalog.", }, inline: { type: "boolean", description: "Return the structured export data inline instead of an S3 signed download URL. Use when a sandboxed environment can't fetch the S3 host. Large catalogs may be truncated by the response-size guard — scope with work_ids if so.", }, }, }, }, executor: withBillingGate(this.pica, "industry-ready export", this.exportIndustryReady.bind(this)), }, { definition: { name: "pica_export_catalog_asset_report", // write:files, not write:catalog: proxy.ts floors every write under // /api/admin/exports/* at write:files (ADMIN_SEGMENT_WRITE_FLOOR), so // a credential holding only write:catalog was refused 403 by the proxy // before the route ran — a scope the tool advertised and the platform // did not accept. scope: "write:files", audiences: ["stdio", "http", "public"], tier: "write", description: "Generate a Catalog Asset Report (CAR) — a unified ZIP bundle with cover PDF, evidence folders, and integrity manifest. Maps creative portfolio data to property-lending equivalents for financial institutions, insurers, PROs, and distributors. Sections include: ownership (title deed equivalent), valuation (surveyor's report), physical assets (contents schedule), agreements (mortgage deed), catalog intelligence (condition report), and summary with equivalence map. " + "work_ids note: ownership/valuation/agreements sections honour work_ids; assets/intelligence/placements sections remain organisation-wide (their underlying service methods are not yet scope-aware — tracked as a follow-up). " + "This is the document for an external reader (lender, insurer, buyer). When the " + "reader is the catalogue owner deciding what to fix next, use pica_export_snapshot " + "instead — same underlying data, ranked as a to-do list. " + "The report this tool returns is UNSIGNED: its declaration can only be attested by a person signed in to PICA, from the export panel. Do not tell the user it is signed, and do not sign it for them.", workflows: ["export-required"], 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: { sections: { type: "object", description: "Which sections to include in the export. Ownership is always included.", properties: { valuation: { type: "boolean", description: "Include earnings, audio intelligence, DSP presence, completeness scores", }, assets: { type: "boolean", description: "Include physical asset register with insurance and valuations", }, agreements: { type: "boolean", description: "Include agreements structure and AI consent status", }, intelligence: { type: "boolean", description: "Include gap analysis, carbon footprint, diligence readiness", }, }, }, work_ids: { type: "array", items: { type: "string" }, description: "Optional list of work UUIDs to scope the CAR to. Omit to include the full catalog.", }, }, }, }, executor: withBillingGate(this.pica, "catalog asset report", this.exportCatalogAssetReport.bind(this)), }, { definition: { name: "pica_export_song_dossiers", // write:files is the floor proxy.ts puts on every write under // /api/admin/exports/* (ADMIN_SEGMENT_WRITE_FLOOR). The route also // requires write:catalog, as the catalog-asset-report route does; a // tool declares one scope, so the description names the second. scope: "write:files", audiences: ["stdio", "http", "public"], tier: "write", description: "Use when the user asks: 'download everything we know about these songs', 'give me the dossier for song A, C and D', 'export everything about this work', 'package these tracks up'. " + "Builds one ZIP holding the song dossier of each work named, as a PDF and as JSON: the work, its recordings with credits and ISRCs, the releases those recordings appear on, the people credited with their identifiers, provenance, AI disclosure and consent. Includes a manifest with a SHA-256 hash of every file and a README. " + "Returns a signed-URL JSON envelope ({ download_url, expires_at, file_size_bytes }) plus works[] saying, per work asked for, whether it was included. Give the user the download_url; it expires in an hour, the stored file in 30 days. " + "Pass 1 to 60 work_ids (UUIDs — resolve titles with pica_works_query or pica_search_all first, and confirm the matches with the user when a title is ambiguous). More than 60, or a malformed id, is refused rather than truncated. A work that does not exist in this catalogue comes back included=false; tell the user, do not drop it silently. " + "The credential needs both write:files and write:catalog. " + "This downloads a file for the catalogue owner. It does not send anything to anyone: to share a work with another person use pica_share_send. " + "For one work's dossier as structured data in this conversation, use pica_works_inspect with sections=['dossier'] instead. " + "For a ranked list of what to fix rather than the full record of each song, use pica_export_snapshot instead.", workflows: ["export-required"], vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { work_ids: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 60, description: "The works to bundle, as UUIDs, in the order they should appear. 1 to 60.", }, }, required: ["work_ids"], }, }, executor: withBillingGate(this.pica, "song dossier bundle", this.exportSongDossiers.bind(this)), }, { definition: { name: "pica_export_snapshot", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Use when the user asks: 'give me a snapshot of my catalogue', 'what shape is my catalogue in', 'what should I fix next'. " + "Export the catalogue snapshot: PICA score with per-pillar detail, financial summary, ownership coverage, gaps ranked by severity each with a suggested action, physical assets, production-asset provenance (the equipment a recording was made on), and a prioritised what-to-do-next list. " + "Scope it with scope='work' plus a single work_id for a deep dive on one work, 'selected' with several work_ids, or 'everything' (default) for the whole catalogue. " + "For a diligence document aimed at a lender or buyer, use pica_export_catalog_asset_report instead — this one is aimed at the catalogue owner deciding what to fix. " + "For everything PICA holds about specific songs (recordings, releases, credits, people), use pica_export_song_dossiers.", workflows: ["export-required"], vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { scope: { type: "string", enum: ["everything", "selected", "work"], description: "everything = whole catalogue (default); selected = the given work_ids; work = exactly one work_id, a deep dive.", }, work_ids: { type: "array", items: { type: "string" }, description: "Work UUIDs. Required for 'work' (exactly one) and 'selected' (one or more); ignored for 'everything'.", }, }, }, }, executor: withBillingGate(this.pica, "catalogue snapshot export", this.exportSnapshot.bind(this)), }, { definition: { name: "pica_export_ownership_record", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Use when the user asks: 'who owns this catalogue?', 'send my lawyer the ownership record', 'prove who owns what'. " + "Export the ownership record — a per-contributor and per-asset register of who owns what, with claim status, share percentages and the definitions a lawyer needs to read it (claim workflow, dispute resolution, what each status means). This is the REGISTER; pica_export_rights_proof is the EVIDENCE behind it. Both exist because they answer different questions for different readers. " + "Returns a signed-URL JSON envelope ({ download_url, expires_at, file_size_bytes }) pointing to the generated ZIP. " + "Pass inline=true to receive the structured data instead, for a sandboxed agent that cannot fetch a signed S3 URL.", workflows: ["export-required"], vernacular_kind: "specialist", vernacular_reason: "Diligence documents are specialist context; added 2026-07-30 (ADR-303) to close a UI/MCP parity gap, not from telemetry.", inputSchema: { type: "object", properties: { inline: { type: "boolean", description: "Return the structured export data inline instead of a signed download URL. Use when you cannot fetch an S3 URL.", }, }, }, }, executor: withBillingGate(this.pica, "ownership record export", this.exportOwnershipRecord.bind(this)), }, { definition: { name: "pica_export_rights_proof", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Use when the user asks: 'prove the chain of title', 'what evidence do we have', 'send the proof bundle to the buyer'. " + "Export the rights proof bundle — timestamped ownership claims, contributor attestations, identity verification and evidence-integrity hashes. This is the EVIDENCE; pica_export_ownership_record is the register it supports. Use this when someone has asked you to prove ownership rather than state it. " + "Returns a signed-URL JSON envelope ({ download_url, expires_at, file_size_bytes }) pointing to the generated ZIP. " + "Pass inline=true to receive the structured data instead, for a sandboxed agent that cannot fetch a signed S3 URL.", workflows: ["export-required"], vernacular_kind: "specialist", vernacular_reason: "Diligence documents are specialist context; added 2026-07-30 (ADR-303) to close a UI/MCP parity gap, not from telemetry.", inputSchema: { type: "object", properties: { inline: { type: "boolean", description: "Return the structured export data inline instead of a signed download URL. Use when you cannot fetch an S3 URL.", }, }, }, }, executor: withBillingGate(this.pica, "rights proof export", this.exportRightsProof.bind(this)), }, { definition: { name: "pica_export_diligence_pack", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Use when the user asks: 'send me the diligence pack', 'what is missing before I register', 'prepare for a catalogue sale'. " + "Export the catalogue diligence pack as a document — readiness scoring, ownership clarity, identifier completeness, uninsured insurable assets, and gaps ranked by severity with a suggested action for each. For the ANSWER rather than the document, call pica_catalog_diligence, which returns the same analysis as JSON and is cheaper; for a lender's asset report, use pica_export_catalog_asset_report. " + "Returns a signed-URL JSON envelope for the generated ZIP. " + "Pass inline=true for the structured data instead, when a sandboxed agent cannot fetch a signed S3 URL.", workflows: ["export-required"], vernacular_kind: "specialist", vernacular_reason: "Diligence documents are specialist context; added 2026-07-30 (ADR-303) to close a UI/MCP parity gap, not from telemetry.", inputSchema: { type: "object", properties: { inline: { type: "boolean", description: "Return the structured export data inline instead of a signed download URL. Use when you cannot fetch an S3 URL.", }, }, }, }, executor: withBillingGate(this.pica, "diligence pack export", this.exportDiligencePack.bind(this)), }, { definition: { name: "pica_export_ai_consent", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'export my AI consent', 'send the consent file to a licensor', " + "'give me the AI consent paperwork', 'what data have I consented to AI training?'. " + "This produces the FILE. For the ANSWER — how much of the catalogue is clearable and " + "who is blocking — use pica_consent_readiness instead; it is one call and returns " + "the rollup directly. " + "Exports the AI usage consent file: contributor-level consent status, per-asset " + "cleared level and permitted uses, and the full grant/revoke history, bundled as a " + "downloadable ZIP with CSVs and a PDF summary. " + "Pass work_ids to scope the works listing to a specific set of works (UUIDs); omit for the full catalog. " + "work_ids note: scopes the works listing but contributor and consent-history rollups remain organisation-wide. " + "For the account holder's own personal data export, use pica_export_my_data.", workflows: ["export-required"], vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { work_ids: { type: "array", items: { type: "string" }, description: "Optional list of work UUIDs to scope the export to. Omit to export the full catalog.", }, }, }, }, executor: this.exportAiConsent.bind(this), }, ]; } async exportCatalogCsv(args) { // Guard the work_id / work_ids requirement client-side so the caller gets a // clear validation error instead of a backend 400 that reads like a data // problem (feedback 2020c81a). const hasWorkId = typeof args.work_id === "string" && args.work_id.length > 0; const hasWorkIds = Array.isArray(args.work_ids) && args.work_ids.length > 0; if (!hasWorkId && !hasWorkIds) { return formatValidationError({ work_id: "Provide exactly one of work_id (single export) or work_ids (bulk export). The export backend requires at least one work to export.", }); } if (hasWorkId && hasWorkIds) { return formatValidationError({ work_id: "Provide either work_id or work_ids, not both.", }); } const result = await this.pica.exports.catalogCsv({ format: args.format, work_id: args.work_id, work_ids: args.work_ids, }); return formatAsText(result); } async exportSongRegistration(args) { const iswcStatus = args?.iswc_status; const workIds = args?.work_ids; const params = {}; if (iswcStatus) params.iswc_status = iswcStatus; if (workIds && workIds.length > 0) params.work_ids = workIds; const result = await this.pica.exports.songRegistration(Object.keys(params).length > 0 ? params : undefined); const data = result?.data ?? result; // ADR-146: Surface export gate warnings const parts = ["Song registration export generated"]; if (data?.exportReadiness?.blockers?.length > 0) { parts.push(`\n--- export gate (${data.exportReadiness.mode} mode) ---`); for (const b of data.exportReadiness.blockers) { parts.push(`${b.type}: ${b.workTitle} — ${b.resolutionHint}`); } if (data.exportReadiness.mode === "advisory") { parts.push("These issues will block export when enforcement mode is enabled."); } } return formatSuccess(parts.join("\n"), data); } async exportIndustryReady(args) { const workIds = args?.work_ids; const inline = args?.inline === true; const result = await this.pica.exports.industryReady({ ...(workIds && workIds.length > 0 ? { work_ids: workIds } : {}), // inline → format=json: the route returns the structured export DATA // inline instead of an S3 signed URL a sandboxed agent can't fetch // (ops_issue 8662c489). The response-size guard truncates if it ever // exceeds the cap. ...(inline ? { format: "json" } : {}), }); return formatSuccess(inline ? "Industry-ready export data (inline)" : "Industry-ready export generated", result); } async exportCatalogAssetReport(args) { const sections = { ownership: true, valuation: args.sections?.valuation ?? true, assets: args.sections?.assets ?? true, agreements: args.sections?.agreements ?? true, intelligence: args.sections?.intelligence ?? true, }; // No attestation: the declaration goes to lenders and insurers, and only a // person signed in to PICA may make it. The route refuses one from an // agent; this tool used to send `signer_name: "MCP Agent"` pre-accepted. const workIds = args?.work_ids; const result = await this.pica.exports.catalogAssetReport({ sections, ...(workIds && workIds.length > 0 ? { work_ids: workIds } : {}), }); const data = result?.data ?? result; let summary = [ `Catalog Asset Report generated`, `Stage: ${data.stage ?? "unknown"}`, `PICA Score: ${data.pica_score ?? "N/A"}${data.pica_grade ? ` (${data.pica_grade})` : ""}`, `Sections: ownership${sections.valuation ? ", valuation" : ""}${sections.assets ? ", assets" : ""}${sections.agreements ? ", agreements" : ""}${sections.intelligence ? ", intelligence" : ""}`, "Attestation: not signed. Only a person signed in to PICA can attest this report, from the export panel.", data.file_size_bytes ? `Size: ${(data.file_size_bytes / 1024).toFixed(1)} KB` : null, data.download_url ? `Download: ${data.download_url}` : null, ] .filter(Boolean) .join("\n"); // ADR-146: Surface export gate warnings if (data?.exportReadiness?.blockers?.length > 0) { summary += `\nExport gate (${data.exportReadiness.mode} mode): ${data.exportReadiness.blockers.length} blocker(s)`; for (const b of data.exportReadiness.blockers.slice(0, 5)) { summary += `\n ${b.type}: ${b.workTitle} — ${b.resolutionHint}`; } if (data.exportReadiness.blockers.length > 5) { summary += `\n ... and ${data.exportReadiness.blockers.length - 5} more`; } } return formatSuccess(summary, data); } async exportSongDossiers(args) { const workIds = Array.isArray(args?.work_ids) ? args.work_ids.filter((id) => typeof id === "string") : []; if (workIds.length === 0) { return formatValidationError({ work_ids: "required: pass the UUIDs of the works to bundle (1 to 60).", }); } const result = await this.pica.exports.songDossiers({ work_ids: workIds }); const data = result?.data ?? result; const works = Array.isArray(data?.works) ? data.works : []; const missing = works.filter((w) => !w.included); const summary = [ `Song dossier bundle generated: ${works.length - missing.length} of ${works.length} work(s) included`, ...works .filter((w) => w.included) .map((w) => ` ✓ ${w.title ?? w.work_id}`), ...missing.map((w) => ` ✗ ${w.work_id} — not found in this catalogue, no dossier`), data?.file_size_bytes ? `Size: ${(data.file_size_bytes / 1024).toFixed(1)} KB` : null, data?.download_url ? `Download (link valid 1 hour): ${data.download_url}` : null, ] .filter(Boolean) .join("\n"); return formatSuccess(summary, data); } async exportAiConsent(args) { const workIds = args?.work_ids; const result = await this.pica.exports.aiConsent(workIds && workIds.length > 0 ? { work_ids: workIds } : undefined); return formatSuccess("AI consent declarations exported — shows disclosure level and training rights per work", result); } async exportOwnershipRecord(args) { const result = await this.pica.exports.ownershipRecord(args?.inline === true ? { inline: true } : undefined); return formatSuccess("ownership record generated", result?.data ?? result); } async exportRightsProof(args) { const result = await this.pica.exports.rightsProof(args?.inline === true ? { inline: true } : undefined); return formatSuccess("rights proof generated", result?.data ?? result); } async exportDiligencePack(args) { const result = await this.pica.exports.diligencePack(args?.inline === true ? { inline: true } : undefined); return formatSuccess("diligence pack generated", result?.data ?? result); } async exportSnapshot(args) { const scope = (args?.scope ?? "everything"); const workIds = args?.work_ids; // The route validates this too, but a 400 reaches the agent as an opaque // failure; saying it here makes the retry obvious. if (scope === "work" && workIds?.length !== 1) { return formatSuccess("scope='work' needs exactly one work_id. Pass one, or use scope='selected' for several.", { error: "INVALID_SCOPE" }); } if (scope === "selected" && !workIds?.length) { return formatSuccess("scope='selected' needs at least one work_id. Pass some, or use scope='everything'.", { error: "INVALID_SCOPE" }); } const result = await this.pica.exports.picaSnapshot({ scope, ...(workIds?.length ? { work_ids: workIds } : {}), }); return formatSuccess("catalogue snapshot generated", result?.data ?? result); } } //# sourceMappingURL=exports.js.map