UNPKG

@withpica/mcp-server

Version:

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

1,062 lines • 55.4 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
/**
 * ADR-333 — who looks after whose catalogue, for assistants.
 *
 * Owner side: pica_relationships_list, _answer, _grant, _revoke, _activity.
 * Organisation side: pica_portfolio_list, _briefing, _invite and
 * pica_relationships_step_down (list, answer and activity serve both sides).
 *
 * Each tool calls one of pica-b5's /api/admin/relationships/** or
 * /api/admin/portfolio routes, which hold no rules of their own: the 1.1
 * service decides every step and refuses with a code and a plain sentence,
 * which these tools pass on as they are.
 *
 * Accepting, changing, revoking and an owner's invitation give or take away
 * access, so they are the person's own decision (ADR-333 D1):
 *  - from a connected app or agent grant, pica_relationships_answer, _grant
 *    and _revoke are `approvalMode: "human"`: the MCP route stages an
 *    approval for the person, and on approval
 *    lib/services/relationship-approvals.ts runs the step through the
 *    service, never this file;
 *  - from the in-app assistant (not staged), the route refuses those steps
 *    as HUMAN_CONFIRMATION_REQUIRED and the tool gives the screen to do it on.
 * Declining, stepping down and an organisation's invitation take nothing
 * from anyone, so an agent may do them.
 *
 * Every step sends the version of the relationship the caller read
 * (expected_version). If the terms changed since, nothing happens and the
 * tool returns the current terms to read again.
 */
import { ApiError } from "@withpica/mcp-sdk";
import { formatSuccess } from "@withpica/mcp-utils";
import { typedError } from "./error-envelope.js";
import { parseApiErrorBody } from "./api-error-body.js";
import { RELATIONSHIP_SCOPE_INPUT_SCHEMA, SCOPE_FIELD_NAMES, scopeArgToApi, } from "./relationship-scope.js";
import { GRANTABLE_RELATIONSHIP_TYPES, INVITATIONS_FOR_YOU_PATH, RELATIONSHIP_STATUSES, RELATIONSHIP_TYPE_LABELS, RELATIONSHIPS_PATH, eventLine, relationshipActivityPath, relationshipPath, scopeSummary, statusLine, } from "./relationship-copy.js";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** 1.1's MAX_PAGE_LIMIT: a relationship page reads limit + 1 rows. */
export const RELATIONSHIP_PAGE_MAX = 999;
/** pica-2f's 1.4 page cap. */
export const PORTFOLIO_PAGE_MAX = 25;
const REASON_MAX = 500;
/** The scope fields a client can be asked for: what they cover comes from the client. */
export const INVITE_SCOPE_FIELDS = [
    "half",
    "territories",
    "term_starts_on",
    "term_ends_on",
];
const DEFAULT_API_URL = "https://withpica.com/api";
/** `https://withpica.com/api` → `https://withpica.com`. */
export function webOrigin(apiUrl) {
    const url = apiUrl || DEFAULT_API_URL;
    return url.replace(/\/api\/?$/, "") || url;
}
const typeEnum = [...GRANTABLE_RELATIONSHIP_TYPES];
const typeLabel = (t) => RELATIONSHIP_TYPE_LABELS[t] ?? t.replace(/_/g, " ");
const RELATIONSHIP_ID = {
    type: "string",
    format: "uuid",
    description: "The relationship's id (relationship_id from pica_relationships_list).",
};
const EXPECTED_VERSION = {
    type: "integer",
    minimum: 0,
    description: "The relationship's version as you last read it (version from pica_relationships_list). If the terms changed since, nothing happens and you get the current terms back to show the person.",
};
const REASON = {
    type: "string",
    maxLength: REASON_MAX,
    description: "Optional. A short reason, shown to the other side and kept in the history.",
};
const CURSOR = {
    type: "string",
    description: "Optional. next_cursor from the previous page.",
};
const TYPE_ARG = {
    type: "string",
    enum: typeEnum,
    description: "administrator: edits and registers songs, sends exports; split and custody changes go to the owner to approve. representative: sees and exports what's in scope; every change goes to the owner to approve. viewer: sees and exports what's in scope. co_owner: sees the songs it has a share in.",
};
const INVITE_SCOPE_SCHEMA = {
    type: "object",
    additionalProperties: false,
    description: "Optional. Which side, where and for how long. The client decides what it covers when they accept.",
    properties: Object.fromEntries(INVITE_SCOPE_FIELDS.map((f) => [
        f,
        RELATIONSHIP_SCOPE_INPUT_SCHEMA.properties[f],
    ])),
};
/** A tool-side validation refusal, typed so audit and clients read it as an error. */
function invalid(fields) {
    return typedError("VALIDATION_ERROR", Object.values(fields).join(" "), {
        fields,
        nothing_written: true,
    });
}
function optionalString(args, key, max) {
    const v = args[key];
    if (v === undefined || v === null)
        return undefined;
    if (typeof v !== "string" || v.length > max)
        return invalid({
            [key]: `${key} must be text of at most ${max} characters.`,
        });
    return v;
}
function pageLimit(args, max) {
    const v = args.limit;
    if (v === undefined)
        return undefined;
    if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > max)
        return invalid({ limit: `limit must be a whole number from 1 to ${max}.` });
    return v;
}
function cursorArg(args) {
    const v = args.cursor;
    if (v === undefined)
        return undefined;
    if (typeof v !== "string" || v.length === 0 || v.length > 500)
        return invalid({
            cursor: "cursor must be next_cursor from the previous page.",
        });
    return v;
}
function isResult(v) {
    return typeof v === "object" && v !== null && "content" in v;
}
/**
 * A scope argument in tool names, checked for unknown fields (the app checks
 * the values). `allowed` limits it further (an invitation to a client).
 */
function scopeArg(value, allowed = Object.keys(SCOPE_FIELD_NAMES)) {
    if (value === undefined)
        return undefined;
    if (typeof value !== "object" || value === null || Array.isArray(value))
        return invalid({ scope: "scope must be an object." });
    const unknown = Object.keys(value).filter((k) => !allowed.includes(k));
    if (unknown.length > 0)
        return invalid({
            scope: `scope can't include ${unknown.join(", ")}. It takes: ${allowed.join(", ")}.`,
        });
    return scopeArgToApi(value);
}
/** The side a step's result was taken from, read from its history row. */
function actingSide(result) {
    const side = result.event?.actorSide;
    return side === "grantor" || side === "grantee" ? side : null;
}
function describeScope(r, side) {
    return scopeSummary(r.scope, r.type, {
        side,
        grantorName: r.grantorOrgName,
    });
}
/** A relationship as the tools return it: ids, plain words, and its version. */
function relationshipView(r, side, origin) {
    const other = side === "grantor"
        ? { id: r.granteeOrgId, name: r.granteeOrgName }
        : { id: r.grantorOrgId, name: r.grantorOrgName };
    const status = statusLine(r, side);
    return {
        relationship_id: r.id,
        ...(side === "grantor"
            ? { looked_after_by: other }
            : { catalogue: { id: r.grantorOrgId, name: r.grantorOrgName } }),
        type: r.type,
        type_label: typeLabel(r.type),
        status: r.status,
        status_text: status.text,
        needs_your_answer: status.needsYou,
        scope: r.scope,
        scope_text: describeScope(r, side),
        proposal_expires_at: r.proposalExpiresAt,
        version: r.version,
        screen_url: `${origin}${relationshipPath(r.id)}`,
    };
}
function inviteView(i) {
    const owner = i.direction === "grantee_invites_owner";
    return {
        invite_id: i.invite_id,
        from: { id: i.inviting_org_id, name: i.inviting_org_name },
        asks: (owner
            ? `${i.inviting_org_name} asks to look after your catalogue`
            : `${i.inviting_org_name} asks you to look after their catalogue`) +
            ` as ${typeLabel(i.type)}`,
        type: i.type,
        scope: i.scope,
        expires_at: i.expires_at,
        answer_for_one_of: i.eligible_organisations,
    };
}
/**
 * A refusal from a relationship route, as a typed result. The route's code
 * and sentence are passed on as they are (upper-cased code), so the person
 * hears the service's own reason. Anything that is not a refusal (a network
 * failure, a 5xx) is rethrown to the registry's error path.
 */
function refusal(err, origin, screen) {
    if (!(err instanceof ApiError) || err.status >= 500 || err.status === 0)
        throw err;
    const body = parseApiErrorBody(err);
    const code = (body.code ?? `HTTP_${err.status}`).toUpperCase();
    const message = body.message ?? "PICA refused this.";
    const details = (body.details ?? {});
    if (code === "HUMAN_CONFIRMATION_REQUIRED") {
        const url = `${origin}${screen.path}`;
        return typedError(code, `Nothing was done: ${screen.doing} needs the person to confirm it themselves in PICA. Give them this link: ${url}`, { screen_url: url, nothing_written: true, retry_safe: false });
    }
    if (code === "STATUS_CHANGED") {
        const current = details.relationship;
        return typedError(code, current
            ? `Nothing was done: the terms changed since you read them. ${changedTerms(current, screen)}`
            : `Nothing was done: the terms changed since you read them. Read the relationship again with pica_relationships_list and show the person the new terms.`, {
            nothing_written: true,
            retry_safe: false,
            ...(current
                ? { relationship: current, current_version: current.version }
                : {}),
            next_tool: "pica_relationships_list",
        });
    }
    const relationshipId = details.relationship_id;
    if (typeof relationshipId === "string") {
        // The invitation was claimed but the accept was refused: it now waits
        // as a proposal, answerable by its own id.
        return typedError(code, message, {
            relationship_id: relationshipId,
            screen_url: `${origin}${relationshipPath(relationshipId)}`,
            next_tool: "pica_relationships_list",
            retry_safe: false,
        });
    }
    return typedError(code, message, {
        nothing_written: true,
        retry_safe: code === "RATE_LIMITED",
    });
}
/**
 * The terms as they now stand, for a step refused because they moved: in the
 * caller's words when their side is known (otherwise naming both
 * organisations), where it stands (a pause is PICA's, with no reason), and a
 * retry only when the step still applies.
 */
function changedTerms(r, screen) {
    const terms = screen.side
        ? `${screen.side === "grantor" ? `${r.granteeOrgName} looks after ` : "you look after "}${describeScope(r, screen.side)}`
        : `${r.granteeOrgName} looks after ${describeScope(r, "grantee")}`;
    const where = statusLine(r, screen.side ?? "grantee").text;
    const live = r.status === "proposed" || r.status === "active";
    const next = !live
        ? "There is nothing to answer now."
        : screen.step === "end"
            ? `To go ahead, end it again with expected_version ${r.version}.`
            : `Show the person the new terms; to go ahead, answer again with expected_version ${r.version}.`;
    return `Now: ${terms}, as ${typeLabel(r.type)} (${where}). ${next}`;
}
function sectionText(label, section, ok) {
    switch (section.state) {
        case "ok":
            return ok(section.data);
        case "not_in_scope":
        case "unavailable":
            return `${label}: ${section.reason || "couldn't check"}`;
        default:
            // failed, or a state added after this copy: never read as a 0.
            return `${label}: couldn't check`;
    }
}
const halfCount = (n, of, what) => n === null || of === null
    ? `${what} not covered`
    : `${n} of ${of} ${what} with gaps`;
/** One client as a line: who, as what, and what needs attention. */
export function portfolioLine(c) {
    const r = c.relationship;
    const access = r.access === "paused"
        ? "paused by pica, so nothing can be done for them while paused"
        : r.access === "not_started"
            ? `not started yet${r.scope.termStartsOn ? ` (starts ${r.scope.termStartsOn})` : ""}`
            : r.changes === "direct"
                ? "you can change their record"
                : r.changes === "proposal"
                    ? "your changes go to them to approve"
                    : "you can look, not change";
    const ending = r.endingSoon && r.termEndsOn ? `; ends ${r.termEndsOn}` : "";
    const parts = [
        sectionText("gaps", c.missing, (d) => [
            halfCount(d.songsWithGaps, d.songs, "songs"),
            halfCount(d.recordingsWithGaps, d.recordings, "recordings"),
        ].join(", ")),
        sectionText("money", c.moneyBlockers, (d) => `${d.blocked} of ${d.songs} songs blocked from being paid${d.unknown > 0 ? ` (${d.unknown} couldn't be checked)` : ""}`),
        sectionText("enquiries", c.enquiries, (d) => `${d.waiting} enquiries waiting`),
        sectionText("deadlines", c.deadlines, (d) => d.total === 0
            ? "no deadlines"
            : d.items.map((i) => `${i.label} ${i.on}`).join(", ")),
    ];
    return `${c.catalogue.name} (${r.typeLabel}; ${access}${ending}): ${parts.join("; ")}`;
}
export class RelationshipTools {
    pica;
    origin;
    constructor(pica, apiUrl) {
        this.pica = pica;
        this.origin = webOrigin(apiUrl);
    }
    getTools() {
        return [
            {
                definition: {
                    name: "pica_relationships_list",
                    scope: "read:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Use when the user asks: 'who can see my catalogue?', 'who looks after my songs?', 'what can my manager or publisher do in PICA?', 'whose catalogues do we look after?', 'has anyone invited me to look after their catalogue?'. " +
                        'The organisations that look after a catalogue, and what each may do. side "owner" (the default): who looks after the catalogue you\'re signed in to. side "organisation": the catalogues your organisation looks after or has asked to. ' +
                        "Each relationship comes with its type (administrator, representative, viewer, co-owner), what it covers in plain words (which songs, which side, where, until when, whether it sees money), where it stands (waiting for an answer and from whom, active, paused by pica, ended), and its version, which every step sends back. " +
                        "Also lists invitations waiting for the person, in either direction, with the organisations they could answer for (answer one with pica_relationships_answer and invite_id). " +
                        "For the day-to-day picture of clients you look after, use pica_portfolio_briefing. Read-only.",
                    workflows: "relationship-required",
                    vernacular_kind: "creator-entry",
                    inputSchema: {
                        type: "object",
                        properties: {
                            side: {
                                type: "string",
                                enum: ["owner", "organisation"],
                                description: "owner (default): who looks after your catalogue. organisation: the catalogues your organisation looks after.",
                            },
                            status: {
                                type: "array",
                                items: { type: "string", enum: [...RELATIONSHIP_STATUSES] },
                                description: 'Optional. Only these statuses (e.g. ["proposed"] for what\'s waiting). Default: all, newest first, ended ones included.',
                            },
                            cursor: CURSOR,
                            limit: {
                                type: "integer",
                                minimum: 1,
                                maximum: RELATIONSHIP_PAGE_MAX,
                                description: `Optional. Up to ${RELATIONSHIP_PAGE_MAX} per page (default 100).`,
                            },
                        },
                        required: [],
                    },
                },
                executor: this.list.bind(this),
            },
            {
                definition: {
                    name: "pica_relationships_answer",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    // Accepting and changing give access; declining refuses it. A
                    // connected app's answer becomes an approval for the person
                    // (lib/services/relationship-approvals.ts runs it once approved).
                    approvalMode: "human",
                    description: "Answer a request to look after a catalogue: accept it, decline it, or change it and send it back. " +
                        "Pass relationship_id, the expected_version you read (from pica_relationships_list) and action: " +
                        '"accept" (optionally narrowing type or scope: accepting can give less than was asked, never more), ' +
                        '"decline" (with an optional reason), or "edit" (a counter-proposal while it\'s waiting, which the other side then answers; or, on an active relationship, the owner\'s change, which applies at once). ' +
                        "To accept an invitation that came by email, pass invite_id and organisation_id (one of answer_for_one_of from pica_relationships_list) instead; invitations can't be declined, they lapse after 30 days. " +
                        "Accepting or changing gives or takes away access, so it is the person's own decision: from a connected app this asks them to approve first (relay the message and check with pica_approval_status), and from PICA's own assistant it gives the link to do it on screen. " +
                        "Read the terms out to the person before answering. If they changed since you read them, nothing happens and you get the new terms back.",
                    workflows: "relationship-required",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Gives or refuses access to a catalogue; the person's own decision, invoked by exact name after reading them the terms.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            relationship_id: RELATIONSHIP_ID,
                            expected_version: EXPECTED_VERSION,
                            action: {
                                type: "string",
                                enum: ["accept", "decline", "edit"],
                                description: "accept, decline, or edit (a counter-proposal, or the owner's change to an active relationship). With invite_id, only accept.",
                            },
                            type: {
                                ...TYPE_ARG,
                                description: `Optional, accept or edit. ${TYPE_ARG.description}`,
                            },
                            scope: {
                                ...RELATIONSHIP_SCOPE_INPUT_SCHEMA,
                                description: "Optional, accept or edit: the fields to change; the rest stay as they are.",
                            },
                            reason: {
                                ...REASON,
                                description: `Optional, decline. ${REASON.description}`,
                            },
                            invite_id: {
                                type: "string",
                                format: "uuid",
                                description: "Instead of relationship_id: an invitation waiting for the person (invite_id from pica_relationships_list).",
                            },
                            organisation_id: {
                                type: "string",
                                format: "uuid",
                                description: "With invite_id: which of the person's organisations answers it (from answer_for_one_of).",
                            },
                        },
                        required: [],
                    },
                    requiredOneOf: [["relationship_id", "invite_id"]],
                },
                executor: this.answer.bind(this),
            },
            {
                definition: {
                    name: "pica_relationships_grant",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    // It gives access as soon as the other side accepts, so it is the
                    // owner's own decision: a connected app's request is approved first.
                    approvalMode: "human",
                    description: "As a catalogue's owner, invite someone at an organisation (a manager, publisher, label) to look after your catalogue, by their email address. " +
                        'Say the type (administrator, representative, viewer, co_owner) and optionally the scope: which songs (catalogue "chosen" with work_ids), which side, where, until when, whether they see money. ' +
                        "They get an email and choose which of their organisations answers; it gives them access the moment they accept, so read the person the terms first. " +
                        "The answer is the same whether or not the address is already on PICA, and invitations lapse after 30 days. " +
                        "From a connected app this asks the person to approve first (relay the message and check with pica_approval_status); from PICA's own assistant it gives the link to do it on screen. " +
                        "Their answer shows in pica_relationships_list.",
                    workflows: "relationship-required",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Gives an organisation access to the owner's catalogue once accepted; the owner's own decision, invoked by exact name.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            email: {
                                type: "string",
                                description: "The person's email address at the organisation.",
                            },
                            type: TYPE_ARG,
                            scope: {
                                ...RELATIONSHIP_SCOPE_INPUT_SCHEMA,
                                description: "Optional. Default: the whole catalogue, both sides, worldwide, no end date; statements and earnings for an administrator only (pass money:false to keep them out), never for the other types unless money:true; the public directory only if you say so (administrators only).",
                            },
                        },
                        required: ["email", "type"],
                    },
                },
                executor: this.grant.bind(this),
            },
            {
                definition: {
                    name: "pica_relationships_revoke",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "destructive",
                    approvalMode: "human",
                    previewMode: "two_step_token",
                    description: "As a catalogue's owner, end an organisation's relationship with your catalogue, or withdraw a request you sent. " +
                        "It takes effect on their very next request: they can no longer see or change anything, and it can't be undone (they would have to be invited again). " +
                        "What they did stays in the history (pica_relationships_activity). Pass relationship_id and the expected_version you read, and optionally a reason they'll see. " +
                        "From a connected app this asks the person to approve first; from PICA's own assistant it gives the link to do it on screen. " +
                        "An organisation ending its own side uses pica_relationships_step_down.",
                    workflows: "relationship-required",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Irreversibly ends an organisation's access to the catalogue; the owner's own decision, invoked by exact name.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            relationship_id: RELATIONSHIP_ID,
                            expected_version: EXPECTED_VERSION,
                            reason: REASON,
                        },
                        required: ["relationship_id", "expected_version"],
                    },
                },
                executor: this.revoke.bind(this),
            },
            {
                definition: {
                    name: "pica_relationships_activity",
                    scope: "read:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Use when the user asks: 'what has my manager done in my catalogue?', 'who changed this?', 'what did we do for this client?'. " +
                        "The history of who did what through a relationship, newest first, kept after it ends. " +
                        "With relationship_id: everything on that one relationship (requests, answers, changes, and each action taken through it). " +
                        'Without it, side "owner" (the default): what every organisation did in your catalogue (org_id narrows to one organisation); side "organisation": what your organisation did for its clients (org_id narrows to one client). ' +
                        "Find relationship_id and org ids with pica_relationships_list. Read-only.",
                    workflows: "relationship-required",
                    vernacular_kind: "creator-entry",
                    inputSchema: {
                        type: "object",
                        properties: {
                            relationship_id: {
                                ...RELATIONSHIP_ID,
                                description: "Optional. One relationship's history.",
                            },
                            side: {
                                type: "string",
                                enum: ["owner", "organisation"],
                                description: "Without relationship_id. owner (default): what organisations did in your catalogue. organisation: what your organisation did for its clients.",
                            },
                            org_id: {
                                type: "string",
                                format: "uuid",
                                description: "Optional, without relationship_id: one organisation (side owner) or one client catalogue (side organisation).",
                            },
                            cursor: CURSOR,
                            limit: {
                                type: "integer",
                                minimum: 1,
                                maximum: RELATIONSHIP_PAGE_MAX,
                                description: `Optional. Up to ${RELATIONSHIP_PAGE_MAX} per page (default 100).`,
                            },
                        },
                        required: [],
                    },
                },
                executor: this.activity.bind(this),
            },
            {
                definition: {
                    name: "pica_portfolio_list",
                    scope: "read:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Use when the user asks: 'which clients do we look after?', 'show me our roster', 'which catalogues can I work on?'. " +
                        "The client catalogues your organisation looks after, one row each: the client, its catalogue id (pass it as `catalogue` to any catalogue tool to act for that client), your type, whether your changes apply directly or go to the client to approve, whether access is open, paused by pica or not started, when it ends, " +
                        "and per client: songs and recordings with gaps, songs blocked from being paid, enquiries waiting and deadlines. A section PICA couldn't check says so; it is never a 0. " +
                        "Only live relationships are listed. A page can be shorter than limit and still not be the last: follow next_cursor until it is null. " +
                        "For what needs doing first, use pica_portfolio_briefing; to ask a new client, pica_portfolio_invite. Read-only.",
                    workflows: "relationship-required",
                    vernacular_kind: "creator-entry",
                    inputSchema: {
                        type: "object",
                        properties: {
                            cursor: CURSOR,
                            limit: {
                                type: "integer",
                                minimum: 1,
                                maximum: PORTFOLIO_PAGE_MAX,
                                description: `Optional. Up to ${PORTFOLIO_PAGE_MAX} clients per page (default 10).`,
                            },
                        },
                        required: [],
                    },
                },
                executor: this.portfolioList.bind(this),
            },
            {
                definition: {
                    name: "pica_portfolio_briefing",
                    scope: "read:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Use when the user asks: 'what needs doing across my clients?', 'what should I work on today?', 'anything urgent for my artists?'. " +
                        "The pica_portfolio_list rows plus, for each client, up to five things that need attention within your scope: a song or recording with gaps, a song blocked from being paid, an enquiry waiting, a deadline. " +
                        "Each names the song or recording id where there is one: fix it with the usual catalogue tools, passing that client's id as `catalogue`. " +
                        "A section PICA couldn't check says so; it is never a 0. Follow next_cursor until it is null. Read-only.",
                    workflows: "relationship-required",
                    vernacular_kind: "creator-entry",
                    inputSchema: {
                        type: "object",
                        properties: {
                            cursor: CURSOR,
                            limit: {
                                type: "integer",
                                minimum: 1,
                                maximum: PORTFOLIO_PAGE_MAX,
                                description: `Optional. Up to ${PORTFOLIO_PAGE_MAX} clients per page (default 10).`,
                            },
                        },
                        required: [],
                    },
                },
                executor: this.portfolioBriefing.bind(this),
            },
            {
                definition: {
                    name: "pica_portfolio_invite",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "As an organisation (a manager, publisher, label), ask a client to let you look after their catalogue, by their email address. " +
                        "Say the type you're asking for (administrator, representative, viewer, co_owner) and optionally which side, where and for how long; the client chooses what it covers when they accept, and nothing is shared until they do. " +
                        "They get an email and answer for one of their own catalogues. The answer is the same whether or not the address is already on PICA, and an invitation lapses after 30 days. " +
                        "Once they accept, the client appears in pica_portfolio_list.",
                    workflows: "relationship-required",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Sends an email to a prospective client on the organisation's behalf; invoked by exact name.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            email: {
                                type: "string",
                                description: "The client's email address.",
                            },
                            type: TYPE_ARG,
                            scope: INVITE_SCOPE_SCHEMA,
                        },
                        required: ["email", "type"],
                    },
                },
                executor: this.invite.bind(this),
            },
            {
                definition: {
                    name: "pica_relationships_step_down",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "destructive",
                    previewMode: "two_step_token",
                    description: "As the organisation looking after a catalogue, end your side of the relationship, or withdraw a request you sent. " +
                        "Your organisation loses access at once and it can't be undone: the client would have to invite you again. The client is told; the history stays. " +
                        'Pass relationship_id and the expected_version you read (from pica_portfolio_list or pica_relationships_list with side "organisation"), and optionally a reason the client will see. ' +
                        "A catalogue's owner ending a relationship uses pica_relationships_revoke.",
                    workflows: "relationship-required",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Irreversibly ends the organisation's access to a client catalogue; invoked by exact name after confirmation.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            relationship_id: RELATIONSHIP_ID,
                            expected_version: EXPECTED_VERSION,
                            reason: REASON,
                        },
                        required: ["relationship_id", "expected_version"],
                    },
                },
                executor: this.stepDown.bind(this),
            },
        ];
    }
    // ───────────────────────────────────────────────────────────────────────
    // Reads
    // ───────────────────────────────────────────────────────────────────────
    async list(args) {
        const side = args.side ?? "owner";
        if (side !== "owner" && side !== "organisation")
            return invalid({ side: 'side must be "owner" or "organisation".' });
        const statuses = args.status;
        if (statuses !== undefined &&
            (!Array.isArray(statuses) ||
                statuses.some((s) => !RELATIONSHIP_STATUSES.includes(s))))
            return invalid({
                status: `status must be a list of: ${RELATIONSHIP_STATUSES.join(", ")}.`,
            });
        const cursor = cursorArg(args);
        if (isResult(cursor))
            return cursor;
        const limit = pageLimit(args, RELATIONSHIP_PAGE_MAX);
        if (isResult(limit))
            return limit;
        let page;
        try {
            page = await this.pica.relationships.list({
                side,
                statuses: statuses,
                cursor,
                limit,
            });
        }
        catch (err) {
            return refusal(err, this.origin, {
                path: RELATIONSHIPS_PATH,
                doing: "this",
            });
        }
        const reader = side === "owner" ? "grantor" : "grantee";
        const relationships = page.rows.map((r) => relationshipView(r, reader, this.origin));
        const waiting = page.waiting_for_you.map(inviteView);
        const needYou = relationships.filter((r) => r.needs_your_answer).length;
        const lines = page.rows.map((row, i) => {
            const r = relationships[i];
            const who = reader === "grantor"
                ? `${row.granteeOrgName} looks after ${r.scope_text}`
                : `you look after ${r.scope_text}`;
            return `- ${who}, as ${r.type_label}: ${r.status_text}`;
        });
        const waitingText = page.waiting_for_you_unavailable === "email_not_verified"
            ? "Invitations by email can't be shown until the person confirms their email address."
            : page.waiting_for_you_unavailable === "check_failed"
                ? "PICA couldn't check for invitations by email just now; that doesn't mean there are none."
                : waiting.length > 0
                    ? `${waiting.length} invitation${waiting.length === 1 ? "" : "s"} by email waiting for the person: ${waiting.map((w) => w.asks).join("; ")}.`
                    : "";
        const heading = relationships.length === 0
            ? side === "owner"
                ? "No organisation looks after this catalogue."
                : "Your organisation doesn't look after any catalogue yet."
            : `${relationships.length} relationship${relationships.length === 1 ? "" : "s"}${needYou > 0 ? `, ${needYou} waiting for the person's answer` : ""}:`;
        const message = [
            heading,
            ...lines,
            waitingText,
            page.nextCursor ? "More on the next page: pass next_cursor." : "",
        ]
            .filter(Boolean)
            .join("\n");
        return formatSuccess(message, {
            side,
            relationships,
            waiting_for_you: waiting,
            ...(page.waiting_for_you_unavailable
                ? { waiting_for_you_unavailable: page.waiting_for_you_unavailable }
                : {}),
            next_cursor: page.nextCursor,
        });
    }
    async activity(args) {
        const id = args.relationship_id;
        const cursor = cursorArg(args);
        if (isResult(cursor))
            return cursor;
        const limit = pageLimit(args, RELATIONSHIP_PAGE_MAX);
        if (isResult(limit))
            return limit;
        let page;
        let screen;
        try {
            if (id !== undefined) {
                if (typeof id !== "string" || !UUID.test(id))
                    return invalid({
                        relationship_id: "relationship_id must be a relationship id from pica_relationships_list.",
                    });
                if (args.side !== undefined || args.org_id !== undefined)
                    return invalid({
                        relationship_id: "Pass relationship_id on its own, or side and org_id without it.",
                    });
                page = await this.pica.relationships.events(id, { cursor, limit });
                screen = relationshipPath(id);
            }
            else {
                const side = args.side ?? "owner";
                if (side !== "owner" && side !== "organisation")
                    return invalid({ side: 'side must be "owner" or "organisation".' });
                const org = args.org_id;
                if (org !== undefined && (typeof org !== "string" || !UUID.test(org)))
                    return invalid({ org_id: "org_id must be an organisation id." });
                page = await this.pica.relationships.activity({
                    side,
                    org: org,
                    cursor,
                    limit,
                });
                screen = relationshipActivityPath(side === "owner" ? org : undefined);
            }
        }
        catch (err) {
            return refusal(err, this.origin, {
                path: RELATIONSHIPS_PATH,
                doing: "this",
            });
        }
        const events = page.rows.map((e) => ({
            at: e.at,
            relationship_id: e.relationshipId,
            kind: e.kind,
            text: eventLine(e),
            ...(e.entityType ? { entity_type: e.entityType } : {}),
            ...(e.entityId ? { entity_id: e.entityId } : {}),
        }));
        const message = [
            events.length === 0
                ? "Nothing in the history yet."
                : `${events.length} entr${events.length === 1 ? "y" : "ies"}, newest first:`,
            ...events.map((e) => `- ${e.at.slice(0, 10)}: ${e.text}`),
            page.nextCursor
                ? "Older entries on the next page: pass next_cursor."
                : "",
        ]
            .filter(Boolean)
            .join("\n");
        return formatSuccess(message, {
            events,
            next_cursor: page.nextCursor,
            screen_url: `${this.origin}${screen}`,
        });
    }
    async portfolioList(args) {
        return this.portfolio(args, false);
    }
    async portfolioBriefing(args) {
        return this.portfolio(args, true);
    }
    async portfolio(args, briefing) {
        const cursor = cursorArg(args);
        if (isResult(cursor))
            return cursor;
        const limit = pageLimit(args, PORTFOLIO_PAGE_MAX);
        if (isResult(limit))
            return limit;
        let page;
        try {
            page = briefing
                ? await this.pica.portfolio.briefing({ cursor, limit })
                : await this.pica.portfolio.list({ cursor, limit });
        }
        catch (err) {
            return refusal(err, this.origin, {
                path: RELATIONSHIPS_PATH,
                doing: "this",
            });
        }
        const clients = page.rows.map((c) => ({
            relationship_id: c.relationshipId,
            catalogue: { id: c.catalogue.orgId, name: c.catalogue.name },
            type: c.relationship.type,
            type_label: c.relationship.typeLabel,
            status: c.relationship.status,
            access: c.relationship.access,
            changes: c.relationship.changes,
            ending_soon: c.relationship.endingSoon,
            term_ends_on: c.relationship.termEndsOn,
            scope: c.relationship.scope,
            version: c.relationship.version,
            missing: c.missing,
            money_blockers: c.moneyBlockers,
            enquiries: c.enquiries,
            deadlines: c.deadlines,
            ...(briefing ? { highlights: c.highlights ?? [] } : {}),
            text: portfolioLine(c),
        }));
        const lines = [];
        for (const c of page.rows) {
            lines.push(`- ${portfolioLine(c)}`);
            if (briefing)
                for (const h of c.highlights ?? [])
                    lines.push(`  · ${h.label}`);
        }
        const message = [
            clients.length === 0
                ? page.nextCursor
                    ? "No clients on this page; there are more: pass next_cursor."
                    : "Your organisation doesn't look after any client catalogue yet."
                : `${clients.length} client${clients.length === 1 ? "" : "s"} (as of ${page.asOf}). To act for one, pass its catalogue id as \`catalogue\`:`,
            ...lines,
            clients.length > 0 && page.nextCursor
                ? "More clients on the next page: pass next_cursor. A short page is not the end."
                : "",
        ]
            .filter(Boolean)
            .join("\n");
        return formatSuccess(message, {
            clients,
            next_cursor: page.nextCursor,
            as_of: page.asOf,
        });
    }
    // ───────────────────────────────────────────────────────────────────────
    // Steps
    // ───────────────────────────────────────────────────────────────────────
    async answer(args) {
        const relationshipId = args.relationship_id;
        const inviteId = args.invite_id;
        if ((relationshipId === undefined) === (inviteId === undefined))
            return invalid({
                relationship_id: "Pass relationship_id (a relationship) or invite_id (an invitation by email), not both.",
            });
        const scope = scopeArg(args.scope);
        if (isResult(scope))
            return scope;
        const type = args.type;
        if (type !== undefined && !typeEnum.includes(type))
            return invalid({ type: `type must be one of: ${typeEnum.join(", ")}.` });
        if (inviteId !== undefined) {
            if (typeof inviteId !== "string" || !UUID.test(inviteId))
                return invalid({
                    invite_id: "invite_id must be an invitation id from pica_relationships_list.",
                });
            if (args.action === "decline")
                return typedError("INVITE_CANNOT_BE_DECLINED", "An invitation can't be declined: if the person doesn't want it, leave it and it lapses after 30 days. Nothing was sent.", { nothing_written: true, retry_safe: false });
            if (args.action !== undefined && args.action !== "accept")
                return invalid({
                    action: 'With invite_id, the only action is "accept".',
                });
            if (args.expected_version !== undefined || args.reason !== undefined)
                return invalid({
                    invite_id: "An invitation takes no expected_version or reason: pass invite_id and organisation_id.",
                });
            const org = args.organisation_id;
            if (typeof org !== "string" || !UUID.test(org))
                return invalid({
                    organisation_id: "organisation_id must be one of the invitation's answer_for_one_of organisations.",
                });
            let result;
            try {
                result = await this.pica.relationships.acceptInvite(inviteId, {
                    organisation_id: org,
                    ...(type !== undefined ? { type: type } : {}),
                    ...(scope ? { scope } : {}),
                });
            }
            catch (err) {
                return refusal(err, this.origin, {
                    path: INVITATIONS_FOR_YOU_PATH,
                    doing: "accepting an invitation",
                });
            }
            // The organisation that answered says which side the person is on:
            // the catalogue (an organisation asked them) or the organisation
            // looking after it (an owner asked them).
            return this.stepResult("accept", result.relationship, org === result.relationship.grantorOrgId ? "grantor" : "grantee");
        }
        if (typeof relationshipId !== "string" || !UUID.test(relationshipId))
            return invalid({
                relationship_id: "relationship_id must be a relationship id from pica_relationships_list.",
            });
        const version = args.expected_version;
        if (typeof version !== "number" ||
            !Number.isInteger(version) ||
            version < 0)
            return invalid({
                expected_version: "expected_version is required: the version you read from pica_relationships_list.",
            });
        const action = args.action;
        if (action !== "accept" && action !== "decline" && action !== "edit")
            return invalid({
                action: 'action must be "accept", "decline" or "edit".',
            });
        const reason = optionalString(args, "reason", REASON_MAX);
        if (isResult(reason))
            return reason;
        if (action === "decline" && (type !== undefined || scope !== undefined))
            return invalid({
                action: "Declining takes no type or scope. To ask for different terms, use edit.",
            });
        if (action !== "decline" && reason !== undefined)
            return invalid({ reason: "A reason goes with decline only." });
        if (action === "edit" && type === undefined && scope === undefined)
            return invalid({ action: "An edit needs a type or scope to change." });
        let result;
        try {
            result = await this.pica.relationships.answer(relationshipId, {
                action,
                expected_version: version,
                ...(type !== undefined ? { type: type } : {}),
                ...(scope ? { scope } : {}),
                ...(reason !== undefined ? { reason } : {}),
            });
        }
        catch (err) {
            return refusal(err, this.origin, {
                path: relationshipPath(relationshipId),
                step: "answer",
                doing: action === "accept"
                    ? "accepting it"
                    : action === "edit"
                        ? "changing it"
                        : "declining it",
            });
        }
        return this.stepResult(action, result.relationship, actingSide(result) ?? "grantor");
    }
    stepResult(action, r, side) {
        const view = relationshipView(r, side, this.origin);
        const whose = side === "grantor" ? "your" : `${r.grantorOrgName}'s`;
        let message;
        if (action === "accept") {
            message = `Accepted: ${r.granteeOrgName} now looks after ${whose} catalogue as ${typeLabel(r.type)} (${view.scope_text}).`;
        }
        else if (action === "decline") {
            message = `Declined. ${side === "grantor" ? r.granteeOrgName : r.grantorOrgName} is told.`;
        }
        else if (r.status === "proposed") {
            message = `Sent back as a counter-proposal: ${typeLabel(r.type)}, ${view.scope_text}. ${side === "grantor" ? r.granteeOrgName : r.grantorOrgName} answers next.`;
        }
        else {
            message = `Changed: ${r.granteeOrgName} is now ${typeLabel(r.type)}, ${view.scope_text}. It applies from their next request.`;
        }
        return formatSuccess(message, { relationship: view });
    }
    async grant(args) {
        return this.sendInvite(args, "owner");
    }
    async invite(args) {
        return this.sendInvite(args, "organisation");
    }
    async sendInvite(args, as) {
        const email = typeof args.email === "string" ? args.email.trim() : "";
        if (!EMAIL.test(email) || email.length > 320)
            return invalid({ email: "email must be an email address." });
        const type = args.type;
        if (!typeEnum.includes(type))
            return invalid({ type: `type must be one of: ${typeEnum.join(", ")}.` });
        const scope = scopeArg(args.scope, as === "organisation" ? INVITE_SCOPE_FIELDS : undefined);
        if (isResult(scope))
            return scope;
        let invite;
        try {
            invite = await this.pica.relationships.invite({
                as,
                email,
                type: type,
                ...(scope ? { scope } : {}),
            });
        }
        catch (err) {
            return refusal(err, this.origin, {
                path: RELATIONSHIPS_PATH,
                doing: "inviting someone to look after your catalogue",
            });
        }
        // The terms as PICA recorded them (defaults filled in), so the person
        // hears exactly what was offered — an administrator sees statements
        // unless money was turned off.
        const recorded = invite.scope;
        const terms = recorded && typeof recorded === "object" && "half" in recorded
            ? ` (${scopeSummary(recorded, type, {
                side: as === "owner" ? "grantor" : "grantee",
                grantorName: "the client",
            })})`
            : "";
        const message = as === "owner"
            ? `Invitation sent to ${email} to look after your catalogue as ${typeLabel(type)}${terms}. Nothing is shared until they accept for one of their organisations; it lapses after 30 days if they don't.`
            : `Invitation sent to ${email} asking to look after their catalogue as ${typeLabel(type)}${terms}. Nothing is shared until they accept and choose what it covers; it lapses after 30 days if they don't.`;
        return formatSuccess(message, { invitation: invite });
    }
    async revoke(args) {
        return this.end(args, "revoke");
    }
    async stepDown(args) {
        return this.end(args, "step_down");
    }
    async end(args, how) {
        const id = args.relationship_id;
        if (typeof id !== "string" || !UUID.test(id))
            return invalid({
                relationship_id: "relationship_id must be a relationship id from pica_relationships_list.",
            });
        const version = args.expected_version;
        if (typeof version !== "number" ||
            !Number.isInteger(version) ||
            version < 0)
            return invalid({
                expected_version: "expected_version is required: the version you read from pica_relationships_list.",
            });
        const reason = optionalString(args, "reason", REASON_MAX);
        if (isResult(reason))
            return reason;
        const body = {
            expected_version: version,
            ...(reason !== undefined ? { reason } : {}),
        };
        let result;
        try {
            result =
                how === "revoke"
                    ? await this.pica.relationships.revoke(id, body)
                    : await this.pica.relationships.stepDown(id, body);
        }
        catch (err) {
            return refusal(err, this.origin, {
                path: relationshipPath(id),
                doing: "ending it",
                side: how === "revoke" ? "grantor" : "grantee",
                step: "end",
            });
        }
        const r = result.relationship;
        const message = how === "revoke"
            ? `Ended: ${r.granteeOrgName} no longer looks after your catalogue, from their next request. They're told, and what they did stays in the history.`
            : `Stepped down: your organisation no longer looks after ${r.grantorOrgName}'s catalogue. They're told, and the history stays.`;
        return formatSuccess(message, {
            relationship: relationshipView(r, how === "revoke" ? "grantor" : "grantee", this.origin),
        });
    }
}
//# sourceMappingURL=relationships.js.map