UNPKG

@withpica/mcp-server

Version:

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

198 lines • 11.4 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { formatStructured, formatSuccess } from "@withpica/mcp-utils"; const MAX_PEOPLE = 200; export class ProfileClaimsTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_profile_claims_invite", scope: "write:people", audiences: ["stdio", "http", "public"], tier: "write", approvalMode: "human", description: "Invite people in this catalogue to claim their profile: each gets " + "an email, at the address the catalogue holds for them, with a link " + "to see and confirm (or correct) their own credits. A claim never " + "lets them edit the catalogue. Use when the user asks: 'invite " + "these writers to check their credits', 'ask everyone on this album " + "to claim their profile'. ALWAYS call first WITHOUT confirm: that " + "sends nothing and returns who would be emailed (and at which " + "address) and who would be skipped and why. Show that to the user, " + "and only call again with confirm: true once they agree. People " + "with no email, already claimed, already linked to a PICA account, " + "the caller themself, already holding a working invite, " + "who asked not to be contacted, or whose address said 'not me' or " + "'I manage them' are skipped. When confirming, pass " + "expected_emails (each person id → the address the preview showed): " + "anyone whose address has changed since is skipped rather than " + "emailed somewhere the user never saw. Up to 200 people per call. " + "From a connected app or agent grant, nothing is sent and no " + "preview comes back: the call is held for the account owner to " + "approve by email (human_approval_required), and the invites go " + "out only when they approve. " + "→ first: pica_people_query (find person ids) " + "→ then: pica_profile_claims_status (see who has answered)", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { person_ids: { type: "array", items: { type: "string" }, minItems: 1, maxItems: MAX_PEOPLE, description: "Ids of people in this catalogue to invite (from pica_people_query).", }, confirm: { type: "boolean", description: "Leave out (or false) for a preview that sends nothing. Set true only after the user has seen the preview and agreed: then the emails are sent.", }, expected_emails: { type: "object", additionalProperties: { type: "string" }, description: "With confirm: each person id → the address the preview showed for them. A person whose address has changed since is skipped (ADDRESS_CHANGED_SINCE_PREVIEW).", }, }, required: ["person_ids"], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, executor: this.invite.bind(this), }, { definition: { name: "pica_profile_claims_status", scope: "read:people", audiences: ["stdio", "http", "public"], tier: "read", description: "Where people in this catalogue stand on claiming their profile. " + "Use when the user asks: 'who has claimed their profile?', 'did " + "Sam answer the invite?', 'who still hasn't checked their " + "credits?'. States: " + "claimed; already linked to a PICA account, so nothing to " + "invite (linked); invited and waiting (pending); invite expired; invited at " + "an address the catalogue has since changed (address_changed); " + "answered 'that's not me' (not_me) or 'I manage this person' " + "(manager); withdrawn (revoked); or not invited. Also says whether " + "the invite email went out (send_status: sent, held for approval, " + "failed) and when it may be sent again (resend_after). Give " + "person_ids to ask about specific people; leave it out to list " + "everyone invited or claimed, newest first. Never returns an email " + "address. " + "→ then: pica_profile_claims_invite (invite or re-invite)", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { person_ids: { type: "array", items: { type: "string" }, maxItems: MAX_PEOPLE, description: "Narrow to these people (ids from pica_people_query). A person never invited reads not_invited, or linked when they are already linked to a PICA account.", }, state: { type: "string", enum: [ "claimed", "linked", "not_invited", "pending", "expired", "address_changed", "person_removed", "not_me", "manager", "revoked", ], description: "Without person_ids: only people in this state (e.g. pending = invited, no answer yet).", }, limit: { type: "integer", minimum: 1, maximum: 200, description: "Without person_ids: max people to list, most recent first (default 50, max 200). If `truncated` comes back true, more matched: narrow with state, or ask by person_ids.", }, }, }, annotations: { readOnlyHint: true, }, }, executor: this.status.bind(this), }, ]; } async invite(args) { const personIds = Array.isArray(args.person_ids) ? args.person_ids.filter((id) => typeof id === "string") : []; const confirm = args.confirm === true; const expected = args.expected_emails && typeof args.expected_emails === "object" && !Array.isArray(args.expected_emails) ? args.expected_emails : undefined; const result = await this.pica.profileClaims.invite({ person_ids: personIds, confirm, ...(expected ? { expected_emails: expected } : {}), }); if (!result) { return formatSuccess("The invite call returned nothing. Call pica_profile_claims_status to see where these people stand before trying again.", { result: null }); } const skipped = result.results.filter((r) => r.outcome === "skipped"); const skipLine = skipped.length > 0 ? ` ${skipped.length} would be skipped: ${skipped .map((r) => `${r.person_id} (${r.code})`) .join(", ")}.` : ""; if (!result.confirmed) { return formatSuccess(`Preview only: nothing was sent. ${result.would_invite} would be emailed an invite to claim their profile.${skipLine} Show the user who would be emailed and at which address; if they agree, call pica_profile_claims_invite again with the same person_ids, confirm: true, and expected_emails mapping each person id to the address shown.`, result); } const held = result.results.filter((r) => r.outcome === "invited" && r.send_status === "held").length; const notSent = result.results.filter((r) => r.outcome === "invited" && r.send_status === "failed").length; const sent = result.invited - held - notSent; const parts = [ sent > 0 ? `Emailed ${sent}.` : "", held > 0 ? `${held} waiting for approval before they go out.` : "", notSent > 0 ? `${notSent} invite(s) made but the email didn't go out; invite them again.` : "", skipped.length > 0 ? `Skipped ${skipped.length}: ${skipped.map((r) => `${r.person_id} (${r.code})`).join(", ")}.` : "", result.failed > 0 ? `${result.failed} failed; try those again.` : "", ].filter(Boolean); return formatSuccess(parts.join(" "), result); } async status(args) { const personIds = Array.isArray(args.person_ids) ? args.person_ids.filter((id) => typeof id === "string") : undefined; const { people, truncated } = await this.pica.profileClaims.status({ ...(personIds && personIds.length > 0 ? { person_ids: personIds } : {}), ...(typeof args.state === "string" ? { state: args.state } : {}), ...(typeof args.limit === "number" ? { limit: Math.floor(args.limit) } : {}), }); const n = people.length; const summary = truncated ? `Showing ${n} ${n === 1 ? "person" : "people"}, most recent first. More matched than are listed: narrow with state, or ask by person_ids. Do not treat this as everyone.` : `Found ${n} ${n === 1 ? "person" : "people"}.`; return formatStructured({ count: n, items: people, truncated }, summary); } } //# sourceMappingURL=profile-claims.js.map