UNPKG

@withpica/mcp-server

Version:

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

213 lines • 9.61 kB
// 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