UNPKG

@withpica/mcp-server

Version:

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

700 lines • 37.1 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { formatList, formatSuccess, notifyParties, summarizeNotifications, AGREEMENTS_WRITE_PROPERTIES, AGREEMENTS_CREATE_REQUIRED, AGREEMENTS_UPDATE_REQUIRED, } from "@withpica/mcp-utils"; import { computeAgreementGapHints } from "./gap-hints.js"; const AGREEMENT_TYPE_ENUM = [ "master_recording", "library_music", "publishing_admin", "production", "artist_contract", "termination", "report", "other", "sub_publishing", "co_publishing", "admin_only", "distribution", ]; export class AgreementsTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_agreements_query", scope: "read:agreements", audiences: ["stdio", "http", "public"], tier: "read", description: "List the user's agreements. Use when the user asks: 'what " + "agreements do I have?', 'list contracts', 'my deals', 'what's " + "still waiting on a signature?'. Filter as needed — returns " + "contracts, publishing deals, and other agreements. For one " + "agreement in full, use pica_agreements_inspect.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search by title or party name", }, agreement_type: { type: "string", description: "Filter by agreement type", enum: [...AGREEMENT_TYPE_ENUM], }, status: { type: "string", description: "Filter by status", enum: [ "draft", "pending_signature", "partially_signed", "fully_executed", "active", "completed", "terminated", "expired", "cancelled", "unknown", ], }, awaiting_signature: { type: "boolean", description: "Filter to agreements still waiting on a signature — " + "status is pending_signature or partially_signed. Use " + "instead of status when you want both in-flight states " + "at once (e.g. 'what's waiting on a signature?').", }, party_name: { type: "string", description: "Filter by other party name", }, start_date_from: { type: "string", description: "Agreements starting on/after this ISO date (YYYY-MM-DD).", }, start_date_to: { type: "string", description: "Agreements starting on/before this ISO date.", }, end_date_from: { type: "string", description: "Agreements ending on/after this ISO date.", }, end_date_to: { type: "string", description: "Agreements ending on/before this ISO date. For 'expiring this year' pass end_date_from=2026-01-01 & end_date_to=2026-12-31.", }, expiring_within_days: { type: "number", description: "Active agreements whose end_date is within this many days from today (e.g. 90). Also scopes to status=active.", }, has_end_date: { type: "boolean", description: "true → only agreements with an end date; false → only open-ended agreements.", }, limit: { type: "number", minimum: 1, maximum: 100, description: "Maximum results (default: 50, max: 100)", }, offset: { type: "number", description: "Pagination offset (default: 0)", }, }, }, }, executor: this.queryAgreements.bind(this), }, { definition: { name: "pica_agreements_inspect", scope: "read:agreements", audiences: ["stdio", "http", "public"], tier: "read", description: "Get the details of one agreement. Use when the user asks: 'show " + "me this contract', 'what's in this agreement?', 'open this " + "contract', 'who still needs to sign?'. Omit sections to get " + "everything. → then: pica_agreements_update (change it), " + "pica_agreements_link_work (attach a work)", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { id: { type: "string", description: "Agreement ID", }, sections: { type: "array", items: { type: "string", enum: ["basic", "works", "parties", "payments", "signatures"], }, description: "Which data sections to include. Omit for all sections. " + '"signatures" gives a per-required-signer view (name, ' + "signed or pending, signed_at) — distinct from the " + 'signatureStatus summary already on "basic".', }, }, required: ["id"], }, }, executor: this.inspectAgreement.bind(this), }, { definition: { name: "pica_agreements_create", scope: "write:agreements", audiences: ["stdio", "http", "public"], tier: "write", description: "Create a new agreement. Use when the user asks: 'create a new " + "agreement', 'start a contract', 'draft a deal'. Needs a title, " + "an agreement_type, and the other party. Where you can, link the " + "other party to a real org via counterparty_org_id (look the org " + "up with pica_search_all) rather than typing the name as free " + "text — linking it lets the user report across all deals with " + "that counterparty later. Prefer " + "pica_agreements_create_from_template when the agreement comes " + "from a template and needs signers set.", workflows: ["agreement-required"], vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: AGREEMENTS_WRITE_PROPERTIES, required: [...AGREEMENTS_CREATE_REQUIRED], }, }, executor: this.createAgreement.bind(this), }, { definition: { name: "pica_agreements_create_from_template", scope: "write:agreements", audiences: ["stdio", "http", "public"], tier: "write", description: "Create a draft agreement from a template — the entry point of " + "the e-signature flow. Use when the user asks: 'create a " + "work-for-hire agreement and send it to James to sign', 'draft " + "an agreement from a template and get it signed', 'create a " + "split sheet agreement for X to sign'. Renders the template " + "against real variables and writes the resulting agreement in " + "draft status with required_signers already set — the only " + "creation path that can do that; pica_agreements_create has no " + "required_signers field. Look up the template first " + "(pica_agreement_types_query type:template, or " + "pica_agreement_templates_render to preview it) so you know " + "its required_fields, and find signer person ids via " + "pica_people_query. → then: pica_agreements_send_for_signature " + "to email the signing links.", workflows: ["agreement-required"], vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { template_id: { type: "string", description: "Agreement template ID (not name) — find it via " + "pica_agreement_types_query with type:template.", }, title: { type: "string", description: "Title for the new agreement", }, agreement_type: { type: "string", enum: [ "master_recording", "library_music", "publishing_admin", "production", "artist_contract", "other", ], description: "Agreement type to record on the new agreement", }, required_signers: { type: "array", items: { type: "string" }, description: "Person UUIDs who must sign, at least one — find them via " + "pica_people_query. The first signer also supplies the " + "counterparty name when other_party_name is omitted.", }, variables: { type: "object", description: "Values for the template's placeholders — check the " + "template's required_fields first (pica_agreement_types_query " + "type:template, or pica_agreement_templates_render) so " + "nothing is missing.", }, other_party_name: { type: "string", description: "Counterparty name for the agreement record. Omit to " + "derive it from the first required signer's person record.", }, description: { type: "string", description: "Optional description for the agreement", }, }, required: [ "template_id", "title", "agreement_type", "required_signers", "variables", ], }, }, executor: this.createFromTemplate.bind(this), }, { definition: { name: "pica_agreements_update", scope: "write:agreements", audiences: ["stdio", "http", "public"], tier: "write", description: "Update an existing agreement. Status changes trigger notifications; " + "counterparty_org_id changes require the linked-org check at the route layer. " + "To email signing links rather than edit fields, use " + "pica_agreements_send_for_signature.", workflows: ["agreement-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: AGREEMENTS_WRITE_PROPERTIES, required: [...AGREEMENTS_UPDATE_REQUIRED], }, }, executor: this.updateAgreement.bind(this), }, { definition: { name: "pica_agreements_delete", scope: "write:agreements", audiences: ["stdio", "http", "public"], tier: "destructive", description: "Delete an agreement (destructive — also removes linked work and party records). " + "To detach one work and keep the agreement, use " + "pica_agreements_link_work with detach:true.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Destructive operation; invoked by exact name after explicit user confirmation in a specialist flow.", inputSchema: { type: "object", properties: { id: { type: "string", description: "Agreement ID", }, }, required: ["id"], }, }, executor: this.deleteAgreement.bind(this), }, { definition: { name: "pica_agreements_link_work", scope: "write:agreements", audiences: ["stdio", "http", "public"], tier: "write", description: "Link a work to an agreement, or unlink one. Optionally set a " + "royalty split percentage when linking. Pass detach:true to " + "unlink the work from the agreement instead — this removes " + "the link only; it does not delete the work or the agreement. " + "Use pica_agreements_inspect with sections:['works'] to see what " + "is already linked.", workflows: ["agreement-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: { agreement_id: { type: "string", description: "Agreement ID", }, work_id: { type: "string", description: "Work ID to link (or unlink, with detach:true)", }, royalty_split_percentage: { type: "number", description: "Royalty split percentage for this work (0-100). Ignored when detach:true.", }, notes: { type: "string", description: "Notes about this work's inclusion in the agreement. Ignored when detach:true.", }, detach: { type: "boolean", description: "Unlink the work from the agreement instead of linking it. " + "Removes the link only — the work and the agreement both " + "stay intact.", }, }, required: ["agreement_id", "work_id"], }, }, executor: this.linkWork.bind(this), }, { definition: { name: "pica_agreement_source_splits", scope: "write:catalog", audiences: ["stdio", "http"], tier: "write", description: "Reconcile the per-party splits you extracted from an UPLOADED agreement against the work's composition credits, and source them if they match. You extract the split table from the document; PICA verifies it deterministically. Outcomes: 'sourced' (matched + confident), 'needs_confirmation' (matched but low confidence — re-call with confirm:true to source anyway), 'discrepancy' (the document disagrees with the system — a finding, do not retry). Match is ±0.5% per party; the party set must correspond. To record a new producer or work-for-hire agreement, use pica_agreement_types_create.", // infrastructure (not a guided creator workflow): an internal reconcile // step invoked after document upload + agent extraction, so it carries // no required-schema companion guidance. workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Invoked after document upload and AI extraction — an internal step in the agreement-sourcing pipeline, not a direct creator question.", inputSchema: { type: "object", properties: { work_id: { type: "string", description: "The work whose splits the agreement ratifies", }, agreement_id: { type: "string", description: "The uploaded agreement", }, extracted_splits: { type: "array", description: "Per-party splits you read from the document", items: { type: "object", properties: { person_id: { type: "string", description: "PICA person id if known", }, name: { type: "string", description: "Party name if person_id is unknown", }, percentage: { type: "number", description: "Split percentage 0-100", }, }, required: ["percentage"], }, }, confidence: { type: "number", description: "Your extraction confidence, 0-1", }, confirm: { type: "boolean", description: "Source despite low confidence (after human confirmation)", }, }, required: [ "work_id", "agreement_id", "extracted_splits", "confidence", ], }, }, executor: this.sourceAgreementSplits.bind(this), }, { definition: { name: "pica_agreements_send_for_signature", scope: "write:agreements", audiences: ["stdio", "http", "public"], tier: "write", // Delegated agents (OAuth/grant) propose; the org owner approves // in-view/email before the signing links go out (ADR-250). Session // + first-party callers confirm in-chat and execute directly. approvalMode: "human", description: "Sends every required signer of a draft agreement a personal " + "signing link by email. Reports who was emailed and who was " + "skipped and why (no_email_on_file, already_signed, " + "link_mint_failed, email_send_failed) — never silently " + "half-done. Requires owner approval for delegated agents; " + "session callers confirm in-chat. Use pica_agreements_inspect " + "first to check the agreement's required_signers and status " + "(must be 'draft').", workflows: ["agreement-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.", annotations: { title: "send agreement for signature" }, inputSchema: { type: "object", properties: { agreement_id: { type: "string", description: "Agreement ID", }, expires_in_days: { type: "number", description: "Days until the signing links expire (default: 30)", minimum: 1, maximum: 365, }, }, required: ["agreement_id"], }, }, executor: this.sendForSignature.bind(this), }, ]; } async queryAgreements(args) { // status is an eq filter and awaiting_signature a two-status .in() // filter — the route ANDs them, so combining them silently returns an // always-empty (or misleadingly narrowed) result. Reject the combination // honestly instead (review fix, ADR-289 Wave B1). if (args.status && args.awaiting_signature === true) { return { content: [ { type: "text", text: "use either status or awaiting_signature, not both — awaiting_signature already covers pending_signature and partially_signed.", }, ], isError: true, }; } // listPage carries the route's real total (list() returned only the // page, so "total" was the page length — row-cap T3). const page = await this.pica.agreements.listPage({ query: args.query, agreement_type: args.agreement_type, status: args.status, awaiting_signature: args.awaiting_signature, party_name: args.party_name, start_date_from: args.start_date_from, start_date_to: args.start_date_to, end_date_from: args.end_date_from, end_date_to: args.end_date_to, expiring_within_days: args.expiring_within_days, has_end_date: args.has_end_date, includeWorkCounts: true, limit: args.limit, offset: args.offset, }); return formatList(page.data, { total: page.total, has_more: page.hasMore, }); } async inspectAgreement(args) { const sections = args.sections; const includeAll = !sections || sections.length === 0; const needsBasic = includeAll || sections.includes("basic") || sections.includes("parties") || sections.includes("payments"); const needsWorks = includeAll || sections.includes("works"); const needsSignatures = includeAll || sections.includes("signatures"); // Fetch data in parallel where possible. The /signatures route is only // called for the STANDALONE sections:["signatures"] case — when "basic" // is also being fetched, agreements.get already computed signatureStatus // server-side, and calling getSignatureStatusWithSigners on top would // recompute that identical status; the signers list derives from the // already-fetched status instead (deriveSignersFromStatus below). const [fullResult, works, signatureStatusWithSigners] = await Promise.all([ needsBasic ? this.pica.agreements.get(args.id) : null, needsWorks ? this.pica.agreements.getWorks(args.id) : null, needsSignatures && !needsBasic ? this.pica.agreements.getSignatureStatusWithSigners(args.id) : null, ]); const result = {}; if (includeAll || sections.includes("basic")) { result.agreement = fullResult.agreement; result.signatureStatus = fullResult.signatureStatus; } if (includeAll || sections.includes("works")) { result.linkedWorks = works; } if (includeAll || sections.includes("signatures")) { // ADR-289 Wave B1 item 3 — per required signer: name, signed/pending, // signed_at. Distinct from the `signatureStatus` summary on "basic". result.signatures = signatureStatusWithSigners ? (signatureStatusWithSigners.signers ?? []) : await this.deriveSignersFromStatus(fullResult.signatureStatus); } if (includeAll || sections.includes("parties")) { // Parties are part of the agreement object const agreement = fullResult.agreement; result.parties = { other_party_name: agreement.other_party_name, other_party_type: agreement.other_party_type, }; } if (includeAll || sections.includes("payments")) { // Payment info is part of the agreement object. // ADR-201 Phase 2: dropped phantom `royalty_rate` and `payment_terms` // columns (template-variable names from system-templates seed, never // actual columns on `agreements`). Surfaced `advance_currency`, // `is_recoupable`, `financial_terms` to match the write surface so // the round-trip parity contract is satisfied. See // docs/audits/2026-04-27-read-write-parity-audit.md § Agreements. const agreement = fullResult.agreement; // ADR-322: without finance access the route withholds the money // columns and marks the row. Say so rather than return an empty-looking // section an agent would read as "no advance, no terms". result.payments = agreement.finance_access_required === true ? { finance_access_required: true, message: "the payment terms of this agreement need finance access. the owner can turn it on in settings → connections.", } : { advance_amount: agreement.advance_amount, advance_currency: agreement.advance_currency, is_recoupable: agreement.is_recoupable, financial_terms: agreement.financial_terms, }; } // ADR-193 Surface 2 — proactive peer-MCP hints when gaps are real. const gap_hints = computeAgreementGapHints(result); const enriched = gap_hints.length > 0 ? { ...result, gap_hints } : result; return formatSuccess("Agreement details", enriched); } /** * Build the per-signer list from an ALREADY-FETCHED signatureStatus (the * shape GET /admin/agreements/[id] embeds) instead of calling the * /signatures route, which would recompute the identical status * server-side (review fix, ADR-289 Wave B1). Only the display names are * missing from that status — resolved per signer via people reads * (typically 1-2 required signers per agreement; the SDK has no * people-by-ids bulk read). A failed name lookup degrades that signer's * name to null rather than dropping the row or failing the section. */ async deriveSignersFromStatus(signatureStatus) { const status = signatureStatus; const requiredSigners = status?.requiredSigners ?? []; if (requiredSigners.length === 0) return []; const signedAtByPerson = new Map((status?.signatures ?? []).map((sig) => [ sig.person_id, sig.signed_at ?? null, ])); const names = await Promise.all(requiredSigners.map(async (personId) => { try { const person = await this.pica.people.get(personId); return (person?.name ?? ([person?.first_name, person?.last_name] .filter(Boolean) .join(" ") || null)); } catch { return null; } })); return requiredSigners.map((personId, i) => ({ person_id: personId, name: names[i] || null, status: signedAtByPerson.has(personId) ? "signed" : "pending", signed_at: signedAtByPerson.get(personId) ?? null, })); } async createAgreement(args) { const agreement = await this.pica.agreements.create(args); // Auto-notify other party if person_id provided let notifySummary = ""; if (args.other_party_person_id) { const results = await notifyParties(this.pica, [ { person_id: args.other_party_person_id, message: `a new ${args.agreement_type || "agreement"} has been created: "${args.title || "untitled"}" — please review`, work_id: args.work_id, notification_type: "agreement_created", }, ]); notifySummary = summarizeNotifications(results); } const msg = notifySummary ? `Agreement created. ${notifySummary}` : "Agreement created"; return formatSuccess(msg, agreement); } async createFromTemplate(args) { const agreement = await this.pica.agreements.createFromTemplate({ templateId: args.template_id, title: args.title, variables: args.variables, requiredSigners: args.required_signers, agreementType: args.agreement_type, description: args.description, otherPartyName: args.other_party_name, }); const message = `agreement created (id: ${agreement.id}, status: ${agreement.status}). ` + `review at https://withpica.com/inspect/agreements/${agreement.id}\n\n` + `next: pica_agreements_send_for_signature to email the signing links`; return formatSuccess(message, agreement); } async updateAgreement(args) { const { id, ...updates } = args; const agreement = await this.pica.agreements.update(id, updates); return formatSuccess("Agreement updated", agreement); } async deleteAgreement(args) { await this.pica.agreements.delete(args.id); return formatSuccess(`Agreement ${args.id} deleted`); } async linkWork(args) { if (args.detach === true) { await this.pica.agreements.unlinkWork(args.agreement_id, args.work_id); return formatSuccess("Work unlinked from agreement", { agreement_id: args.agreement_id, work_id: args.work_id, detached: true, }); } const link = await this.pica.agreements.linkWork(args.agreement_id, { work_id: args.work_id, royalty_split_percentage: args.royalty_split_percentage, notes: args.notes, }); return formatSuccess("Work linked to agreement", link); } async sourceAgreementSplits(args) { const result = await this.pica.agreements.sourceAgreementSplits(args.work_id, args.agreement_id, args.extracted_splits, args.confidence, args.confirm); return formatSuccess("Agreement split reconcile complete", result); } async sendForSignature(args) { const result = await this.pica.agreements.sendForSignature(args.agreement_id, { expires_in_days: args.expires_in_days }); const lines = []; if (result.sent.length > 0) { lines.push(`sent to: ${result.sent.map((s) => s.name).join(", ")}`); } if (result.skipped.length > 0) { lines.push(`skipped: ${result.skipped .map((s) => `${s.name} — ${SEND_FOR_SIGNATURE_SKIP_REASON_TEXT[s.reason]}`) .join("; ")}`); } const summary = lines.length > 0 ? lines.join("\n") : "no required signers found"; const message = `agreement sent for signature (status: ${result.status}). ${summary}\n\n` + `review at https://withpica.com/inspect/agreements/${result.agreement_id}`; return formatSuccess(message, result); } } /** Plain-language reason text for a skipped signer (ADR-289). */ const SEND_FOR_SIGNATURE_SKIP_REASON_TEXT = { no_email_on_file: "no email on file, add one to their person record", already_signed: "already signed", link_mint_failed: "couldn't generate a signing link — try resending", email_send_failed: "email failed to send — try resending", }; //# sourceMappingURL=agreements.js.map