@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
302 lines • 15.4 kB
JavaScript
// 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