@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
992 lines • 54.5 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { elicitBulkConfirmation, formatSuccess, formatList, } from "@withpica/mcp-utils";
/**
* What to tell an agent whose invite email did not arrive. The invite and
* resend responses carry the link once (`invite_url`; the server keeps only
* its hash), so the link itself goes in the message. A response without one
* (an older server) falls back to the claim code.
*/
function shareTheLink(result) {
const url = result && typeof result === "object"
? result.invite_url
: undefined;
return typeof url === "string" && url.length > 0
? `share this link with them directly (it is shown only this once): ${url}`
: "share the invite's claim code";
}
function capitalise(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
export class CollaboratorsTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
return [
{
definition: {
name: "pica_collaborators_invite",
scope: "write:people",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Invite someone to verify their credit on a work. Use when the " +
"user asks: 'let's notify the people on this song', 'ask everyone " +
"to verify', 'get my collaborators to confirm'. Reach them by " +
"email, or by their PICA handle if they're on another org — with " +
"a handle, PICA finds the email itself and the sending agent " +
"never sees it. Creates the person if they're new, sends the " +
"invite, and sets up their credit. If it's unclear which the user " +
"has, ask: 'do you have their email, or their PICA handle?' " +
"Targets a work, a recording, or a whole release. " +
"Prefer pica_collaborators_invite_bulk for several people on the " +
"same work, by email.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
work_id: {
type: "string",
description: "The work ID to invite the collaborator to. Exactly one of work_id / recording_id / release_id.",
},
recording_id: {
type: "string",
description: "Recording ID — invite someone to verify their credit on this recording (Producer, Mixer, etc.). Exactly one of work_id / recording_id / release_id.",
},
release_id: {
type: "string",
description: "Release ID — invite someone to verify ALL their credits across this release's tracks (both recording credits and composition credits) in one action. Exactly one of work_id / recording_id / release_id.",
},
name: {
type: "string",
description: "Full name of the collaborator",
},
email: {
type: "string",
description: "Email address of the collaborator. Either this OR `handle` must be set. When `handle` is set, leave this empty — PICA resolves server-side.",
},
handle: {
type: "string",
description: "PICA public handle (e.g. 'josh-allen'). Resolved server-side to the recipient's email — sender's agent never learns the email. Use `pica_users_find_by_handle` first to confirm the handle and the recipient's `handle_accepts_invites` flag. Either this OR `email` must be set.",
},
role: {
type: "string",
description: "Role on the work (e.g. 'producer', 'songwriter', 'drummer', 'vocalist')",
},
invited_by: {
type: "string",
description: "Person ID of the person sending the invite (the catalog owner). Use pica_people_query to find this ID.",
},
credit_type: {
type: "string",
enum: [
"writer",
"composer",
"lyricist",
"arranger",
"producer",
"performer",
"vocalist",
"instrumentalist",
"engineer",
"mixer",
"mastering",
"programmer",
"remixer",
"conductor",
],
description: "Type of credit. Use one of the enum values (e.g. 'producer', 'writer', 'vocalist', 'instrumentalist'). Common synonyms are normalised server-side (production→producer, songwriting→writer, drums/guitar/bass→instrumentalist, vocals→vocalist, mixing→mixer), but prefer the canonical value.",
},
percentage_split: {
type: "number",
description: "Percentage split for this credit (0-100)",
},
},
required: ["invited_by", "credit_type"],
},
},
executor: this.inviteCollaborator.bind(this),
},
{
definition: {
name: "pica_collaborators_invites_list",
scope: "read:people",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "See the invites the user has sent and where each one stands. Use " +
"when the user asks: 'who's pending invites?', 'who hasn't " +
"accepted yet?', 'outstanding invites'. Filter by status (pending " +
"/ verified / confirmed / expired / cancelled / disputed) or by " +
"work. For invites sent TO the user, see " +
"pica_collaborators_incoming_list instead.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
description: "Filter by status (default: 'pending')",
enum: [
"pending",
"verified",
"confirmed",
"expired",
"cancelled",
"disputed",
],
},
work_id: {
type: "string",
description: "Filter invites for a specific work",
},
limit: {
type: "number",
description: "Maximum results to return (default: 50, max: 100)",
},
},
},
},
executor: this.listInvites.bind(this),
},
{
definition: {
name: "pica_collaborators_incoming_list",
scope: "read:people",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "List collaboration invites addressed to YOUR email across every inviting org. Returns pending invites by default — pass status to see disputed/confirmed history. Each row includes work_title, inviter_org_name, credit_type, and percentage_split so you can decide whether to accept. " +
"For invites the user sent instead, use " +
"pica_collaborators_invites_list.",
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: {
status: {
type: "string",
description: "Filter by status (default: 'pending')",
enum: [
"pending",
"verified",
"confirmed",
"expired",
"cancelled",
"disputed",
],
},
limit: {
type: "number",
description: "Maximum results to return (default: 50, max: 100)",
},
},
},
},
executor: this.listIncoming.bind(this),
},
{
definition: {
name: "pica_collaborators_accept",
scope: "write:people",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Accept a collaboration invite addressed to your email. Writes a real collaborator credit row (work_collaborators) on the inviter's work and notifies them. Fails if the invite is not addressed to your authenticated email. Use pica_collaborators_incoming_list to find the invite_id first.",
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: {
invite_id: {
type: "string",
description: "The invite id to accept",
},
},
required: ["invite_id"],
},
},
executor: this.acceptInvite.bind(this),
},
{
definition: {
name: "pica_collaborators_dispute",
scope: "write:people",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Push back on an invite that isn't right — 'that's not me' or 'I " +
"wasn't on this work'. Use when the user asks: 'I disagree with " +
"this', 'dispute that', 'contest this credit'. Marks the invite " +
"as disputed and lets the sender know. No credit is created. " +
"Prefer pica_collaborators_decline_by_code when the user has a " +
"claim code rather than an invite id.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
invite_id: {
type: "string",
description: "The invite id to dispute",
},
reason: {
type: "string",
description: "Optional freeform reason for the dispute",
},
},
required: ["invite_id"],
},
},
executor: this.disputeInvite.bind(this),
},
{
definition: {
name: "pica_collaborators_invite_bulk",
scope: "write:people",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Send multiple collaboration invites in one call. Every row is a " +
"work invite by email — work_id, name, email, invited_by and " +
"credit_type, with role and percentage_split optional. It cannot " +
"take a recording, a release or a PICA handle, so for those send " +
"one at a time with pica_collaborators_invite. Returns per-row " +
"{ ok, email, invite?, error? } so partial failures are visible.",
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: {
invites: {
type: "array",
description: "Array of invites to send",
items: {
type: "object",
properties: {
work_id: {
type: "string",
description: "UUID of the work to invite the collaborator to",
},
name: {
type: "string",
description: "Full name of the collaborator",
},
email: {
type: "string",
description: "Email address to send the invite to",
},
role: {
type: "string",
description: "Role on the work (e.g. writer, producer)",
},
invited_by: {
type: "string",
description: "Name of the person sending the invite",
},
credit_type: {
type: "string",
enum: [
"writer",
"composer",
"lyricist",
"arranger",
"producer",
"performer",
"vocalist",
"instrumentalist",
"engineer",
"mixer",
"mastering",
"programmer",
"remixer",
"conductor",
],
description: "Type of credit. Use one of the enum values (e.g. 'producer', 'writer'). Synonyms are normalised server-side but prefer the canonical value.",
},
percentage_split: {
type: "number",
description: "Split percentage (0-100)",
},
},
required: [
"work_id",
"name",
"email",
"invited_by",
"credit_type",
],
},
},
dry_run: {
type: "boolean",
description: "When true, returns { dry_run: true, would_affect, sample } without sending any invite emails. Bulk invites send real email — preview before committing.",
},
},
required: ["invites"],
},
previewMode: "dry_run",
},
executor: this.inviteBulk.bind(this),
},
{
definition: {
name: "pica_collaborators_invite_resend",
scope: "write:people",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Resend a pending collaboration invite. Regenerates the invite token and sends a new email. " +
"Prefer pica_collaborators_invite_revoke when the user wants to " +
"withdraw the invite rather than send it again.",
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: {
invite_id: {
type: "string",
description: "The invite ID to resend",
},
},
required: ["invite_id"],
},
},
executor: this.resendInvite.bind(this),
},
// ADR-236 — code-based recipient claim / decline + sender status/revoke
{
definition: {
name: "pica_collaborators_claim_by_code",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "write",
description: "Preview or accept an invite the user received, using its claim " +
"code. Use when the user asks: 'I have a code from someone', " +
"'accept the invite ABCD-23N7', 'preview the invite, then " +
"accept'. Call with accept: false first to show the proposed " +
"credit, then again with accept: true to confirm — the user " +
"should see what they're agreeing to before they say yes. " +
"Prefer pica_collaborators_accept when the user has an invite id " +
"from pica_collaborators_incoming_list rather than a code.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
code: {
type: "string",
description: "8-char claim code (hyphenated form like DKZH-4N7P is fine; normalised server-side)",
},
accept: {
type: "boolean",
description: "false (default) → preview only, no mutation; true → atomically accept the invite and write the collaborator credit in the inviter's org scope.",
},
},
required: ["code"],
},
},
executor: this.claimByCode.bind(this),
},
{
definition: {
name: "pica_collaborators_decline_by_code",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "write",
description: "Decline an invite the user received, using its claim code. Use " +
"when the user asks: 'decline the invite ABCD-23N7', 'I don't " +
"want this credit'. You can pass a short reason (up to 500 " +
"chars), which the sender will see. " +
"Prefer pica_collaborators_counter_propose when the user wants " +
"different terms rather than to refuse outright.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
code: {
type: "string",
description: "8-char claim code",
},
reason: {
type: "string",
description: "Optional free-text reason for declining (max 500 chars). Sent to the inviter; helps avoid re-invitation loops.",
},
},
required: ["code"],
},
},
executor: this.declineByCode.bind(this),
},
{
definition: {
name: "pica_collaborators_invite_status",
scope: "read:people",
audiences: ["stdio", "http"],
tier: "read",
description: "Check where a single invite the user sent has got to. Use when " +
"the user asks: 'has Josh accepted yet?', 'what's the status of " +
"the invite I just sent?', 'when does that invite expire?', 'show " +
"me the negotiation history'. Pass invite_id, or work_id to get " +
"that work's most recent invite. Add include_history: true to see " +
"the full back-and-forth of counter-proposals. " +
"For every outstanding invite at once, use " +
"pica_collaborators_invites_list; this one covers a single one.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
invite_id: {
type: "string",
description: "The invite ID to look up",
},
work_id: {
type: "string",
description: "Work ID — returns the most recently created invite for this work in the caller's org",
},
include_history: {
type: "boolean",
default: false,
description: "When true, response includes `counters` — the chronological counter-proposal chain (each entry: proposed_by_user_id, proposed_at, credit_type, percentage_split, message, status, decided_at, decided_by_user_id). Defaults to false for backwards compatibility.",
},
},
},
},
executor: this.inviteStatus.bind(this),
},
{
definition: {
name: "pica_collaborators_invite_revoke",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "destructive",
description: "Pull back an invite the user already sent. Use when the user " +
"asks: 'cancel that invite', 'revoke the invite I just sent', " +
"'pull the invite to Josh'. Two steps, since it can't be undone: " +
"the first call returns a confirmation token and a summary to " +
"show the user, the second call (with that token) revokes it and " +
"kills both the link and the code. If the recipient is already a " +
"PICA user, they're told it was withdrawn. " +
"Prefer pica_collaborators_invite_resend when the user only wants " +
"to nudge the recipient again.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
invite_id: {
type: "string",
description: "The invite ID to revoke",
},
confirmation_token: {
type: "string",
description: "Second-call confirmation token returned by the first call. Required to actually mutate.",
},
},
required: ["invite_id"],
},
},
executor: this.revokeInvite.bind(this),
},
// ADR-238 — agent-mediated counter-proposals on collaboration invites
{
definition: {
name: "pica_collaborators_counter_propose",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "write",
description: "Propose different terms on an invite the user received. Use when " +
"the user asks: 'I want 40 not 50', 'counter that invite at 35 " +
"percent', 'propose a different split'. Pass the new credit_type, " +
"percentage_split, and an optional note. The sender can then " +
"accept, decline, or counter back — up to 5 rounds between the " +
"two parties. For the sender's reply to a counter, use " +
"pica_collaborators_counter_back; this one is the recipient's.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
code: {
type: "string",
description: "8-char claim code from the invite (hyphenated form like DKZH-4N7P is fine; normalised server-side)",
},
credit_type: {
type: "string",
description: "The counter-proposed credit role (writer, composer, lyricist, arranger, producer, performer, engineer, etc.). May differ from the original.",
},
percentage_split: {
type: "number",
description: "Counter-proposed split, 0-100",
},
message: {
type: "string",
description: "Optional free-text reasoning (max 500 chars). Surfaced to the sender alongside the counter values.",
},
},
required: ["code", "credit_type", "percentage_split"],
},
},
executor: this.proposeCounter.bind(this),
},
{
definition: {
name: "pica_collaborators_accept_counter",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "write",
description: "Accept the latest counter-offer and lock in the credit. Use when " +
"the user asks: 'accept their counter', 'yes, agree to 40 " +
"percent', 'take the counter from Josh'. Writes the credit using " +
"the counter's terms (not the original invite's) and confirms it. " +
"The original recipient stays the one who holds the credit, " +
"whoever proposed the final terms. Prefer " +
"pica_collaborators_decline_counter when the user rejects it.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
counter_id: {
type: "string",
description: "UUID of the pending counter to accept (returned by counter_propose / counter_back; also surfaced in pica_collaborators_invite_status with include_history)",
},
},
required: ["counter_id"],
},
},
executor: this.acceptCounter.bind(this),
},
{
definition: {
name: "pica_collaborators_decline_counter",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "write",
description: "Turn down the latest counter-offer. Use when the user asks: " +
"'decline that counter', 'no, 35 is too low', 'reject the counter " +
"from Josh'. The invite goes back to pending, so the original " +
"terms are on the table again and either side can move next " +
"(counter back, accept the original, or revoke). A short reason " +
"(up to 500 chars) reaches the other party. Prefer " +
"pica_collaborators_counter_back when the user wants to answer " +
"with new terms rather than just refuse.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
counter_id: {
type: "string",
description: "UUID of the pending counter to decline",
},
reason: {
type: "string",
description: "Optional free-text reason (max 500 chars). Surfaced to the proposer via discovery event.",
},
},
required: ["counter_id"],
},
},
executor: this.declineCounter.bind(this),
},
{
definition: {
name: "pica_collaborators_counter_back",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "write",
description: "Come back with a new offer after the recipient countered. Use " +
"when the user asks: 'counter back at 45', 'meet them halfway, " +
"propose 45 percent', 'come back at 42'. Replaces the recipient's " +
"counter with the user's new one and notifies them — up to 5 " +
"rounds between the two parties. Prefer " +
"pica_collaborators_accept_counter when the user is happy with " +
"the recipient's terms as they stand.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
counter_id: {
type: "string",
description: "UUID of the recipient's counter to respond to (pending or recently declined)",
},
credit_type: {
type: "string",
description: "Sender's new proposed credit role (writer, composer, producer, etc.)",
},
percentage_split: {
type: "number",
description: "Sender's new proposed split, 0-100",
},
message: {
type: "string",
description: "Optional free-text reasoning (max 500 chars). Surfaced to the recipient alongside the counter values.",
},
},
required: ["counter_id", "credit_type", "percentage_split"],
},
},
executor: this.counterBack.bind(this),
},
];
}
async inviteCollaborator(args) {
// ADR-237: discriminated-union recipient. Exactly one of `handle`
// or `email` must be set. When `handle` is set, the SDK forwards
// it to the route handler which resolves to email server-side.
const handle = typeof args.handle === "string" && args.handle.trim() !== ""
? args.handle.trim()
: null;
const email = typeof args.email === "string" && args.email.trim() !== ""
? args.email.trim()
: null;
if (handle && email) {
throw new Error("Pass either `handle` or `email`, not both. Use `handle` for ADR-237 cross-org discovery (PICA resolves to email server-side).");
}
if (!handle && !email) {
throw new Error("One of `handle` or `email` is required.");
}
const targetCount = [
args.work_id,
args.recording_id,
args.release_id,
].filter(Boolean).length;
if (targetCount !== 1) {
throw new Error("Exactly one of `work_id`, `recording_id`, `release_id` is required.");
}
const inviteParams = {
workId: args.work_id,
recordingId: args.recording_id,
releaseId: args.release_id,
personData: {
name: args.name,
// Email is intentionally omitted when handle is the source —
// sendInvite resolves via handleService and splices in the
// canonical workspace_email.
...(email ? { email } : {}),
role: args.role,
},
invitedBy: args.invited_by,
creditData: {
credit_type: args.credit_type,
percentage_split: args.percentage_split,
},
};
if (handle) {
inviteParams.recipient = { kind: "handle", value: handle };
}
else if (email) {
inviteParams.recipient = { kind: "email", value: email };
}
const result = await this.pica.collaborators.invite(inviteParams);
// ADR-264 W2d: emit the collaborator_missing_identifiers hint ONLY when
// the invited person currently lacks BOTH IPI and ISNI in person_identifiers.
//
// The route now appends person_id + has_ipi + has_isni to the response so
// this check works in production (not just in mocked tests). Three cases:
// 1. person_id null (brand-new person, not yet in people table) —
// conservatively emit the standard hint.
// 2. person_id set, has_ipi || has_isni true — suppress hint.
// 3. person_id set, both false — emit the standard hint.
// If the response doesn't carry the identity fields (unexpected shape),
// fall back to the conservative "emit" path — never wrongly suppress.
const hints = (() => {
const r = result;
const personId = r.person_id;
const hasIpi = r.has_ipi;
const hasIsni = r.has_isni;
if (!personId || hasIpi === undefined || hasIsni === undefined) {
// Fix 5: unexpected shape (brand-new person or route didn't supply
// identity fields) — use softer "could not confirm" wording and
// "nice_to_have" severity to distinguish this read-failure / uncertain path (CompletionHint has no "info")
// from the confirmed-gap path below (which has "important" severity
// and asserts the absence positively).
return [
{
gap: "collaborator_missing_identifiers",
suggestion: `could not confirm whether ${args.name ?? "the recipient"} has an IPI/ISNI — check before enriching (pica_resolve_person)`,
severity: "nice_to_have",
},
];
}
if (hasIpi || hasIsni) {
// Person already has an industry identifier — hint not needed.
return [];
}
// Confirmed gap: person exists and has neither IPI nor ISNI.
return [
{
gap: "collaborator_missing_identifiers",
suggestion: `Enrich ${args.name ?? "the recipient"} with ISNI or IPI for royalty collection (pica_resolve_person)`,
severity: "important",
},
];
})();
// ADR-237: when handle was the source, never echo email back in
// the success message — the sender's agent must not learn the
// recipient's email. The route handler is responsible for
// stripping email from `result` too; we belt-and-brace at the
// tool layer.
const recipientLabel = handle
? `@${handle}`
: `${args.name ?? "recipient"} (${email})`;
if (handle && result && typeof result === "object") {
// Defensive scrub of common email fields the underlying invite
// payload may carry. Display fields (handle, display_name)
// stay; PII does not.
const r = result;
delete r.email;
if (r.person_data && typeof r.person_data === "object") {
delete r.person_data.email;
}
}
const target = args.work_id
? `work ${args.work_id}`
: args.recording_id
? `recording ${args.recording_id}`
: `release ${args.release_id}`;
// Don't claim "sent" when the email actually failed to deliver. The
// invite row is persisted either way; email_delivered:false means the
// recipient got no email (Postmark suppression / rate-limit / bounce /
// circuit-breaker) and needs a resend or the link shared directly.
// `undefined` (older route without the field) keeps the plain message.
const emailDelivered = result && typeof result === "object"
? result.email_delivered
: undefined;
// The invite response carries the link once (`invite_url`); only its
// hash is stored, so this is the one moment it can be handed on.
const message = emailDelivered === false
? `Invite created for ${recipientLabel} on ${target} (credit "${args.credit_type}"), but the invitation email could not be delivered. Resend with pica_collaborators_invite_resend, or ${shareTheLink(result)}.`
: `Invite sent to ${recipientLabel} for credit "${args.credit_type}" on ${target}.`;
return formatSuccess(message, result, hints);
}
async listInvites(args) {
// ADR-157 sub-step 4b (superseded form): the original plan surfaced
// an "events since last review" watermark hint, but sub-step 4a
// already delivers confirmed/disputed receipts instantly via
// Telegram — making the duplicated completion-hint surface
// redundant. ADR-160 reinforces the receipts-layer principle. All
// that remains is nudging unpaired orgs towards Telegram, via a
// single parallel call to the existing telegram status endpoint.
// Status-check failure degrades to "no nudge" so the primary read
// path stays resilient.
const [invites, telegramStatus] = await Promise.all([
this.pica.collaborators.listInvites({
status: args.status,
workId: args.work_id,
limit: args.limit,
}),
this.pica.telegram.getStatus().catch(() => null),
]);
const hints = [];
if (telegramStatus && telegramStatus.connected === false) {
hints.push({
gap: "telegram_not_connected",
suggestion: "Want collaborator accept/dispute receipts delivered instantly? Pair Telegram with pica_telegram_connect — the message body is the receipt, no dashboard trip.",
severity: "nice_to_have",
});
}
return formatList(invites, {
status: args.status || "pending",
completion_hints: hints,
});
}
async resendInvite(args) {
const result = await this.pica.collaborators.resendInvite(args.invite_id);
const delivered = result && typeof result === "object"
? result.email_delivered
: undefined;
return formatSuccess(delivered === false
? `Invite ${args.invite_id} was renewed, but the reminder email could not be delivered. ${capitalise(shareTheLink(result))}.`
: `Invite ${args.invite_id} resent successfully.`, result);
}
async listIncoming(args) {
const invites = await this.pica.collaborators.listIncoming({
status: args.status,
limit: args.limit,
});
const hints = [];
if (invites.length > 0) {
hints.push({
gap: "pending_incoming_invites",
suggestion: `You have ${invites.length} pending invite${invites.length === 1 ? "" : "s"}. Accept with pica_collaborators_accept or reject with pica_collaborators_dispute.`,
severity: "important",
});
}
return formatList(invites, {
status: args.status || "pending",
count: invites.length,
completion_hints: hints,
});
}
async acceptInvite(args) {
const result = await this.pica.collaborators.accept(args.invite_id);
const hints = [
{
gap: "credit_attested",
suggestion: "Your credit is now live on the inviter's work. Use pica_works_query to see works you're credited on.",
severity: "nice_to_have",
},
];
return formatSuccess(`Invite ${args.invite_id} accepted. Credit written.`, result, hints);
}
async disputeInvite(args) {
const result = await this.pica.collaborators.dispute(args.invite_id, args.reason);
return formatSuccess(`Invite ${args.invite_id} disputed. The inviter has been notified.`, result);
}
async inviteBulk(args, ctx) {
// ADR-199 Stream 5b — dry_run preview before invite-email send.
// Bulk invites send real email through Postmark; dry_run lets the
// caller verify the recipient list before triggering the send.
if (args.dry_run === true) {
const invites = args.invites || [];
const sample = invites.slice(0, 3).map((i) => ({
email: i.email,
name: i.name,
work_id: i.work_id,
credit_type: i.credit_type,
}));
return formatSuccess(`Dry run — would send ${invites.length} collaboration invite(s)`, {
dry_run: true,
would_affect: invites.map((i) => i.email),
sample,
target_count: invites.length,
});
}
// ADR-200 Phase 1 — Class D form-confirmation gate. Bulk invites
// dispatch real email through Postmark; once sent, they cannot be
// unsent. Surface the recipient count and a sample for the user to
// confirm before the batch fires.
const invitesArr = args.invites || [];
const sampleEmails = invitesArr
.slice(0, 3)
.map((i) => i.email)
.join(", ");
const tail = invitesArr.length > 3 ? `, +${invitesArr.length - 3} more` : "";
const gate = await elicitBulkConfirmation({
server: ctx?.server,
args,
action: "Send bulk collaboration invites",
impactSummary: `${invitesArr.length} invite(s) (${sampleEmails}${tail})`,
warning: "Each invite triggers a real email; sent emails cannot be recalled.",
});
if (gate.kind === "abort")
return gate.result;
const results = await this.pica.collaborators.inviteBulk(invitesArr.map((i) => ({
workId: i.work_id,
personData: { name: i.name, email: i.email, role: i.role },
invitedBy: i.invited_by,
creditData: {
credit_type: i.credit_type,
percentage_split: i.percentage_split,
},
})));
// Classify per-row outcomes instead of a flat sent/failed split. Two
// distinct non-success shapes were previously conflated: an invite that
// was CREATED but whose email bounced comes back ok:true with
// email_delivered:false (delivery is non-fatal — the invite link still
// works), while a recipient that could not be resolved at all throws
// recipient_not_found and used to be reported as a bare "failed" with a
// raw error string.
const rows = results;
const undeliverable = rows.filter((r) => r.ok && r.invite?.email_delivered === false);
const sent = rows.filter((r) => r.ok && r.invite?.email_delivered !== false);
const notFound = rows.filter((r) => !r.ok && /recipient_not_found|handle not found/i.test(r.error ?? ""));
const failed = rows.filter((r) => !r.ok && !/recipient_not_found|handle not found/i.test(r.error ?? ""));
const parts = [`${sent.length} sent`];
if (undeliverable.length > 0) {
parts.push(`${undeliverable.length} created but the email couldn't be delivered (resend with pica_collaborators_invite_resend, or share each invite's link directly: invite_url in its result, shown only this once)`);
}
if (notFound.length > 0) {
parts.push(`${notFound.length} recipient(s) not found (${notFound
.map((r) => r.email)
.filter(Boolean)
.join(", ")}) — check the handle/email or create the person first`);
}
if (failed.length > 0)
parts.push(`${failed.length} failed`);
return formatSuccess(`Bulk invite complete: ${parts.join("; ")}.`, {
results,
ok: sent.length + undeliverable.length,
sent: sent.length,
undeliverable: undeliverable.length,
recipient_not_found: notFound.length,
failed: failed.length,
});
}
// ADR-236 — code-based recipient claim / decline + sender status/revoke
async claimByCode(args) {
const accept = args.accept === true;
if (!accept) {
const preview = await this.pica.collaborators.previewByCode(args.code);
return formatSuccess(`Invite preview for code ${args.code}: "${preview.work_title ?? "(work)"}" — ${preview.credit_type}${preview.percentage_split ? ` at ${preview.percentage_split}%` : ""} from ${preview.inviter_display_name ?? "the inviter"}. Call again with accept=true to confirm.`, preview);
}
const result = await this.pica.collaborators.acceptByCode(args.code);
const hints = [
{
gap: "credit_attested",
suggestion: "Your credit is now live on the inviter's work. Use pica_collaborations_received to see all works you're credited on across orgs.",
severity: "nice_to_have",
},
];
return formatSuccess(`Invite claimed by code ${args.code}. Credit written in the inviter's catalog.`, result, hints);
}
async declineByCode(args) {
const result = await this.pica.collaborators.declineByCode(args.code, args.reason);
return formatSuccess(`Invite declined by code ${args.code}. The inviter's agent will surface the decline on their next prompt.`, result);
}
async inviteStatus(args) {
const result = await this.pica.collaborators.inviteStatus({
invite_id: args.invite_id,
work_id: args.work_id,
include_history: args.include_history === true,
});
return formatSuccess(`Invite status retrieved.`, result);
}
async revokeInvite(args) {
// The two-step confirmation_token gate is enforced upstream in the
// tool dispatcher (mcp-server/src/tools/index.ts) for any tool with
// tier: "destructive". By the time this executor runs, the token has
// been validated and consumed; we just do the mutation.
const result = await this.pica.collaborators.revokeInvite(args.invite_id);
return formatSuccess(`Invite ${args.invite_id} revoked. Both URL and code claim paths are dead.`, result);
}
// ADR-238 — counter-proposal executors. Each delegates to the matching
// SDK method, which POSTs to the corresponding /counters/* route. Error
// mapping (invalid_code / not_recipient / not_counter_party /
// counter_no_longer_pending / counter_not_actionable / not_sender /
// invite_no_longer_valid / negotiation_limit_reached) is owned by the
// routes; recovery hints in mcp-server/src/tools/recovery-hints.ts pair
// each error_code with an agent-friendly suggestion + next_tool.
async proposeCounter(args) {
const result = await this.pica.collaborators.proposeCounter({
code: args.code,
credit_type: args.credit_type,
percentage_split: args.percentage_split,
message: args.message,
});
return formatSuccess(`Counter proposed on invite (${args.credit_type} · ${args.percentage_split}%). The sender has been notified — they can accept, decline, or counter back.`, result);
}
async acceptCounter(args) {
const result = await this.pica.collaborators.acceptCounter(args.counter_id);
return formatSuccess(`Counter ${args.counter_id} accepted. Credit written with the counter's values; the invite is now confirmed.`, result);
}
async declineCounter(args) {
const result = await this.pica.collaborators.declineCounter(args.counter_id, args.reason);
return formatSuccess(`Counter ${args.counter_id} declined. The invite reverts to pending — the original proposal is back on the table.`, result);
}
async counterBack(args) {
const result = await this.pica.collaborators.counterBack({
counter_id: args.counter_id,
credit_type: args.credit_type,
percentage_split: args.percentage_split,
message: args.message,
});
return formatSuccess(`Counter-back proposed (${args.credit_type} · ${args.percentage_split}%). The recipient has been notified.`, result);
}
}
//# sourceMappingURL=collaborators.js.map