@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
213 lines • 9.61 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
/**
* ADR-333 — plain words for a relationship (who looks after whose
* catalogue), as the assistant tools say them.
*
* The MCP package cannot import the app, so this keeps its own copy of what
* the screens say: the type labels and statuses (from
* lib/services/portfolio-relationship-model.ts), the screen addresses (from
* lib/config/relationship-paths.ts), and the status and history sentences
* (from app/inspect/lib/relationship-copy.ts). The tools and the screens
* cannot say different things without
* lib/services/__tests__/relationship-scope-mcp-parity.test.ts failing: it
* compares every constant here with the app's and runs both sentence
* builders over every status and event kind.
*
* Pure: no imports.
*/
export const GRANTABLE_RELATIONSHIP_TYPES = [
"co_owner",
"administrator",
"representative",
"viewer",
];
export const RELATIONSHIP_TYPE_LABELS = {
co_owner: "co-owner with a share",
administrator: "administrator",
representative: "representative",
viewer: "viewer",
};
export const RELATIONSHIP_STATUSES = [
"proposed",
"active",
"declined",
"revoked",
"stepped_down",
"expired",
"suspended",
];
/** What a type may do, said to the catalogue's owner. */
export const TYPE_DOES = {
administrator: "can edit and register your songs and send exports for you. changes to splits or custody come to you to approve.",
representative: "can see and export what's in scope, and suggest changes. every change to your record comes to you to approve first.",
viewer: "can see and export what's in scope, and nothing else.",
co_owner: "can see the songs and recordings it has a share in. it can't change your facts.",
};
/** The same allowances, said to the organisation that holds them. */
export const TYPE_DOES_FOR_ORG = {
administrator: "can edit and register their songs and send exports for them. changes to splits or custody go to them to approve.",
representative: "can see and export what's in scope, and suggest changes. every change to their record goes to them to approve first.",
viewer: "can see and export what's in scope, and nothing else.",
co_owner: "can see the songs and recordings it has a share in. it can't change their facts.",
};
// ─────────────────────────────────────────────────────────────────────────────
// Screen addresses (paths; the tools prefix the web origin)
// ─────────────────────────────────────────────────────────────────────────────
export const RELATIONSHIPS_PATH = "/inspect/relationships";
export function relationshipPath(relationshipId) {
return `${RELATIONSHIPS_PATH}/${encodeURIComponent(relationshipId)}`;
}
export const INVITATIONS_FOR_YOU_PATH = `${RELATIONSHIPS_PATH}#invitations-for-you`;
export function relationshipActivityPath(granteeOrgId) {
return granteeOrgId
? `${RELATIONSHIPS_PATH}/activity?org=${encodeURIComponent(granteeOrgId)}`
: `${RELATIONSHIPS_PATH}/activity`;
}
/** Hand-overs (ADR-333 D5); the app's are lib/config/handover-paths.ts. */
export const HANDOVERS_PATH = "/inspect/handovers";
export function handoverPath(handoverId) {
return `${HANDOVERS_PATH}/${encodeURIComponent(handoverId)}`;
}
export const NEW_HANDOVER_PATH = `${HANDOVERS_PATH}/new`;
/** Where a relationship stands, for the side reading it. */
export function statusLine(r, reader) {
const other = reader === "grantor" ? r.granteeOrgName : r.grantorOrgName;
const day = (iso) => (iso ? iso.slice(0, 10) : "");
switch (r.status) {
case "proposed": {
const lapses = r.proposalExpiresAt
? ` · expires ${day(r.proposalExpiresAt)}`
: "";
return r.proposedBySide === reader
? { text: `waiting for ${other} to answer${lapses}`, needsYou: false }
: {
text: `${other} is asking · needs your answer${lapses}`,
needsYou: true,
};
}
case "suspended":
return {
text: `paused by pica ${day(r.suspendedAt)} · it grants nothing while paused. ask pica if you have questions.`,
needsYou: false,
};
case "active":
return {
text: `active since ${day(r.respondedAt) || day(r.proposedAt)}${r.scope.termEndsOn ? ` · ends ${r.scope.termEndsOn}` : ""}`,
needsYou: false,
};
case "declined":
return { text: `declined ${day(r.respondedAt)}`, needsYou: false };
case "revoked":
return { text: `ended by the owner ${day(r.endedAt)}`, needsYou: false };
case "stepped_down":
return {
text: `${r.granteeOrgName} stepped down ${day(r.endedAt)}`,
needsYou: false,
};
case "expired":
return {
text: r.endReason === "proposal_unanswered"
? `lapsed unanswered ${day(r.endedAt)}`
: `term ended ${day(r.endedAt)}`,
needsYou: false,
};
}
}
/** One audit row as a sentence: who did what, through which relationship. */
export function eventLine(e) {
const org = e.actorSide === "grantor"
? e.grantorOrgName
: e.actorSide === "grantee"
? e.granteeOrgName
: null;
// The PICA team acts as PICA: its people are not named to customers.
const who = e.actorSide === "platform"
? "pica"
: e.actorDisplayName
? org
? `${e.actorDisplayName} (${org})`
: e.actorDisplayName
: (org ?? "pica");
const type = RELATIONSHIP_TYPE_LABELS[e.type] ?? humanise(e.type);
switch (e.kind) {
case "proposed":
return `${who} proposed ${e.granteeOrgName} as ${type}`;
case "accepted":
return `${who} accepted ${e.granteeOrgName} as ${type}`;
case "declined":
return `${who} declined`;
case "scope_changed":
return `${who} changed what's covered`;
case "revoked":
return `${who} ended the relationship`;
case "stepped_down":
return `${who} stepped down`;
case "expired":
return e.reason === "proposal_unanswered"
? "the request lapsed unanswered"
: "the term ended";
case "term_ending_soon": {
const on = detailDate(e, "term_ends_on") ?? e.scope.termEndsOn;
return `the term ends on ${on ?? "its end date"}`;
}
case "proposal_expiring_soon": {
const on = detailDate(e, "proposal_expires_at");
return `the request lapses${on ? ` on ${on}` : " soon"} if nobody answers`;
}
case "suspended":
return "pica paused it: it grants nothing while paused";
case "reinstated":
return "pica resumed it";
case "acted":
return `${who} ${humanise(e.action) || "acted"}${e.entityType ? ` · ${humanise(e.entityType)}` : ""}`;
default:
// A kind added after this copy: said by its name, never dropped.
return `${who} · ${humanise(e.kind)}`;
}
}
function detailDate(e, key) {
const v = e.detail?.[key];
return typeof v === "string" && v.length >= 10 ? v.slice(0, 10) : null;
}
function humanise(s) {
return (s ?? "").replace(/_/g, " ").trim();
}
/**
* The scope in one line for the side reading it: what, which side, where,
* when, money and (administrators) the directory. Territories are ISO codes
* here ("GB, IE"); the screens spell the country names.
*/
export function scopeSummary(scope, type, reader) {
const whose = reader.side === "grantor" ? "your" : `${reader.grantorName}'s`;
const songs = scope.workIds?.length ?? scope.songsChosen ?? 0;
const recordings = scope.recordingIds?.length ?? scope.recordingsChosen ?? 0;
const count = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
const what = scope.catalogue === "whole"
? `${whose} whole catalogue`
: [
songs > 0 ? count(songs, "song") : null,
recordings > 0 ? count(recordings, "recording") : null,
]
.filter(Boolean)
.join(" and ") + ` from ${whose} catalogue`;
const half = scope.half === "both"
? "songs and recordings"
: scope.half === "composition"
? "the song side only"
: "the recording side only";
const where = scope.territories.includes("WW")
? "worldwide"
: scope.territories.join(", ");
const when = (scope.termStartsOn ? `from ${scope.termStartsOn}` : "from acceptance") +
(scope.termEndsOn ? ` until ${scope.termEndsOn}` : ", no end date");
const money = scope.money
? "can see statements"
: "can't see statements or earnings";
const directory = type === "administrator"
? scope.directory
? "; can list songs in the public directory"
: "; can't list songs in the public directory"
: "";
return `${what}, ${half}, ${where}, ${when}; ${money}${directory}`;
}
//# sourceMappingURL=relationship-copy.js.map