UNPKG

@withpica/mcp-server

Version:

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

302 lines • 15.4 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /** * ADR-333 D5 — building a catalogue for a client and handing it over, for * assistants (plan #2808 §10). * * Organisation side: pica_handover_create, pica_handover_send. Client side: * pica_handover_answer, which only says where to answer. * * Each organisation-side tool calls one /api/admin/handovers route, which * holds no rules of its own: lib/services/catalogue-handovers.ts decides * every step and refuses with a code and a plain sentence, passed on as is. * * Sending emails a client and offers them a catalogue, so it is the person's * own decision (plan §10, "sending needs a human confirm"): * - from a connected app or agent grant, pica_handover_send is * `approvalMode: "human"`: the MCP route stages an approval, and on * approval lib/services/handover-approvals.ts sends through the service, * never this file; * - from a credential the route refuses as HUMAN_CONFIRMATION_REQUIRED, the * tool gives the screen to send it on. * * Accepting ownership is a person's own act (Orchestrator ruling 2026-10-12, * option A): the client's routes take a signed-in session only, and no key * reads them. pica_handover_answer therefore reads nothing and changes * nothing; it gives the screen where the person answers. */ 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 { webOrigin } from "./relationships.js"; import { HANDOVERS_PATH, NEW_HANDOVER_PATH, handoverPath, } 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@]+$/; const NAME_MAX = 200; const EMAIL_MAX = 320; /** The relationship the organisation keeps once the client accepts. */ export const HANDOVER_KEPT_TYPES = [ "administrator", "representative", "viewer", ]; const HANDOVER_ID = { type: "string", format: "uuid", description: "The hand-over's id (handover_id from pica_handover_create, or from the hand-over's link).", }; function invalid(fields) { return typedError("VALIDATION_ERROR", Object.values(fields).join(" "), { fields, nothing_written: true, }); } function text(args, key, max, what) { const v = args[key]; if (typeof v !== "string" || v.trim().length === 0 || v.length > max) return invalid({ [key]: `${key} is required: ${what}, at most ${max} characters.`, }); return v.trim(); } function isResult(v) { return typeof v === "object" && v !== null && "content" in v; } /** * A refusal from a hand-over route, as a typed result: the service's code * and sentence as they are. HUMAN_CONFIRMATION_REQUIRED becomes the screen * to do it on. A network failure or a 5xx is rethrown to the registry. */ 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."; 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 }); } return typedError(code, message, { nothing_written: true, retry_safe: code === "RATE_LIMITED", }); } export class HandoverTools { pica; origin; constructor(pica, apiUrl) { this.pica = pica; this.origin = webOrigin(apiUrl); } getTools() { return [ { definition: { name: "pica_handover_create", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Use when the user asks: 'set up a catalogue for my client', 'build Alice's catalogue so she can take it over', 'start a hand-over'. " + "For an organisation that prepares a catalogue for a client who will own it. Creates the catalogue, with your organisation as its temporary builder; nothing is sent and nobody is emailed. " + "While it is being prepared you can enter facts, but nothing can be published, agreed or signed in your organisation's favour. " + "relationship_type is what your organisation keeps once the client accepts. " + "To send again on a catalogue whose last hand-over was declined, expired or withdrawn, pass catalogue_org_id instead of catalogue_name. " + "When it is ready, send it with pica_handover_send.", workflows: "handover-required", vernacular_kind: "specialist", vernacular_reason: "Creates a catalogue owned by no one yet, for a named client; used by organisations that build catalogues for clients, by exact name.", inputSchema: { type: "object", properties: { catalogue_name: { type: "string", maxLength: NAME_MAX, description: "The new catalogue's name, usually the client's name as they'd want to see it. Give this or catalogue_org_id, not both.", }, catalogue_org_id: { type: "string", format: "uuid", description: "Instead of catalogue_name: a catalogue your organisation built whose last hand-over was declined, expired or withdrawn, to send again.", }, client_name: { type: "string", maxLength: NAME_MAX, description: "The client's name.", }, client_email: { type: "string", format: "email", maxLength: EMAIL_MAX, description: "The client's email. They accept by signing in to PICA with this address, once it is confirmed.", }, relationship_type: { type: "string", enum: [...HANDOVER_KEPT_TYPES], description: "What your organisation keeps once they accept. administrator: keeps the details up to date and registers songs; split and custody changes need the owner's yes. representative: sees the catalogue and suggests changes the owner approves. viewer: sees it, nothing more.", }, }, required: ["client_name", "client_email", "relationship_type"], }, requiredOneOf: [["catalogue_name", "catalogue_org_id"]], }, executor: this.create.bind(this), }, { definition: { name: "pica_handover_send", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", approvalMode: "human", description: "Send a catalogue your organisation prepared to its client, who then has 60 days to accept and become its owner. " + "It emails the client at the address given to pica_handover_create. You can withdraw it on screen until they answer. " + "Pass handover_id, and expected_version if you have it; if the hand-over changed since, nothing is sent. " + "From a connected app this asks the person to approve first; otherwise, when PICA needs the person to confirm, it gives the link to send it on screen.", workflows: "handover-required", vernacular_kind: "specialist", vernacular_reason: "Emails a client and offers them ownership of a catalogue; the organisation's own decision, invoked by exact name.", inputSchema: { type: "object", properties: { handover_id: HANDOVER_ID, expected_version: { type: "integer", minimum: 1, description: "Optional. The hand-over's version as you last read it. If it changed since, nothing is sent.", }, }, required: ["handover_id"], }, }, executor: this.send.bind(this), }, { definition: { name: "pica_handover_answer", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'someone prepared a catalogue for me, how do I take it over?', 'I got an email to accept my catalogue', 'how do I accept my catalogue?'. " + "Shows where to accept a catalogue prepared for you. It does not accept or decline, and it reads nothing. " + "Accepting ownership is the person's own act: they sign in with the address it was sent to, keep or remove each item that names the organisation that built it, then accept or decline. " + "Give handover_id from the email's link to point at that one; without it, the link lists everything waiting for them. " + "For an organisation preparing one, use pica_handover_create.", workflows: "handover-required", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { handover_id: { ...HANDOVER_ID, description: "Optional. The hand-over's id, from the link in the email they received.", }, }, required: [], }, }, executor: this.answer.bind(this), }, ]; } async create(args) { const named = args.catalogue_name !== undefined; const reissue = args.catalogue_org_id !== undefined; if (named === reissue) return invalid({ catalogue_name: "Give catalogue_name for a new catalogue, or catalogue_org_id to send one again; not both.", }); let catalogue; if (reissue) { if (typeof args.catalogue_org_id !== "string" || !UUID.test(args.catalogue_org_id)) return invalid({ catalogue_org_id: "catalogue_org_id must be a catalogue's id.", }); catalogue = { catalogue_org_id: args.catalogue_org_id }; } else { const name = text(args, "catalogue_name", NAME_MAX, "the catalogue's name"); if (isResult(name)) return name; catalogue = { catalogue_name: name }; } const clientName = text(args, "client_name", NAME_MAX, "the client's name"); if (isResult(clientName)) return clientName; const clientEmail = text(args, "client_email", EMAIL_MAX, "the client's email"); if (isResult(clientEmail)) return clientEmail; if (!EMAIL.test(clientEmail)) return invalid({ client_email: "client_email is not an email address." }); const type = args.relationship_type; if (!HANDOVER_KEPT_TYPES.includes(type)) return invalid({ relationship_type: `relationship_type must be one of: ${HANDOVER_KEPT_TYPES.join(", ")}.`, }); let created; try { created = await this.pica.handovers.create({ ...catalogue, client_name: clientName, client_email: clientEmail, relationship_type: type, }); } catch (err) { return refusal(err, this.origin, { path: NEW_HANDOVER_PATH, doing: "preparing a catalogue for a client", }); } const url = `${this.origin}${handoverPath(created.handover_id)}`; return formatSuccess(`Prepared a catalogue for ${clientName}. Nothing has been sent. When it's ready, send it with pica_handover_send; the person can also review and send it here: ${url}`, { handover_id: created.handover_id, catalogue_org_id: created.catalogue_org_id, status: "building", screen_url: url, }); } async send(args) { const id = args.handover_id; if (typeof id !== "string" || !UUID.test(id)) return invalid({ handover_id: "handover_id must be a hand-over's id.", }); const v = args.expected_version; if (v !== undefined && (typeof v !== "number" || !Number.isInteger(v) || v < 1)) return invalid({ expected_version: "expected_version must be a whole number from 1.", }); let sent; try { sent = await this.pica.handovers.send(id, v === undefined ? {} : { expected_version: v }); } catch (err) { return refusal(err, this.origin, { path: handoverPath(id), doing: "sending a hand-over", }); } const url = `${this.origin}${handoverPath(id)}`; return formatSuccess(`Sent to ${sent.client_name}. They have until ${sent.expires_at?.slice(0, 10) ?? "the deadline"} to accept. Withdraw it here until they answer: ${url}`, { handover_id: sent.handover_id, status: sent.status, sent_at: sent.sent_at, expires_at: sent.expires_at, version: sent.version, screen_url: url, }); } async answer(args) { const id = args.handover_id; if (id !== undefined && (typeof id !== "string" || !UUID.test(id))) return invalid({ handover_id: "handover_id must be the id from the hand-over's link.", }); const url = `${this.origin}${id ? handoverPath(id) : HANDOVERS_PATH}`; return formatSuccess(`Nothing was accepted or declined: that's done by the person, signed in to PICA with the address it was sent to. Give them this link: ${url}`, { screen_url: url, nothing_written: true }); } } //# sourceMappingURL=handovers.js.map