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