@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
223 lines • 12.9 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { formatSuccess } from "@withpica/mcp-utils";
export class UsersTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
return [
{
definition: {
name: "pica_users_find_by_handle",
scope: "read:people",
audiences: ["stdio", "http"],
tier: "read",
description: "Use when the user asks: 'find @josh-allen', 'is anyone on PICA called X', 'look up a producer by their PICA handle'. " +
"Exact-match lookup of a public PICA handle across all organisations. Returns the public profile (display name, optional avatar, optional bio, handle_accepts_invites) on a match, or `{found: false}` for unclaimed handles. " +
'Never reveals email — for invite delivery use pica_collaborators_invite with `recipient: {kind: "handle", value: ...}` which resolves to email server-side. ' +
"Rate-limited at 60 lookups/minute/agent_grant_id. " +
"For your own profile, use pica_users_get_my_profile.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition.",
inputSchema: {
type: "object",
properties: {
handle: {
type: "string",
description: "The PICA handle to resolve (3-30 chars, [a-z0-9_-], case-insensitive). 'Josh-Allen' canonicalises to 'josh-allen'.",
},
},
required: ["handle"],
},
},
executor: this.findByHandle.bind(this),
},
{
definition: {
name: "pica_users_set_handle",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "write",
description: "Use when the user asks: 'claim handle X', 'set my PICA handle to X', 'I want to be @josh-allen'. " +
"Claim or replace your public PICA handle. Validation: 3-30 chars, [a-z0-9_-], must start with a letter, no consecutive hyphens/underscores, no leading/trailing separator, not in the reserved list. " +
"Storage is canonical lowercase. Returns the canonical form. " +
"Failure shapes: handle_invalid (+ detail), handle_reserved, handle_already_taken, handle_change_too_soon (a handle changes once every 30 days). " +
"handle_held_for_review is not a failure: a handle that looks like a well-known artist's name or one already taken waits for the PICA team, shows as waiting in the person's settings, and the person is emailed the answer; don't retry. " +
"Rate-limited at 10 attempts/hour/agent_grant_id (deters mass enumeration of which handles are taken). " +
"To give a handle up, use pica_users_clear_handle.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas.",
inputSchema: {
type: "object",
properties: {
handle: {
type: "string",
description: "The handle to claim (3-30 chars).",
},
},
required: ["handle"],
},
},
executor: this.setHandle.bind(this),
},
{
definition: {
name: "pica_users_clear_handle",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "write",
description: "Use when the user asks: 'remove my handle', 'clear my PICA handle', 'I don't want to be @josh-allen anymore'. " +
"Clear your public PICA handle. Idempotent. In-flight invites that resolved your handle at send-time stay valid because the invite holds your resolved email, not a reference to the handle. " +
"To claim or replace a handle rather than remove it, use pica_users_set_handle.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas.",
inputSchema: { type: "object", properties: {} },
},
executor: this.clearHandle.bind(this),
},
{
definition: {
name: "pica_users_get_my_profile",
scope: "read:people",
audiences: ["stdio", "http"],
tier: "read",
description: "Use when the user asks: 'show my profile', 'what's my PICA handle', 'what bio do I have'. " +
"Return the caller's full profile: workspace_email, full_name, public_handle, handle_accepts_invites, avatar_url, bio, linked_person_id. Caller's own profile only — to look up someone else use pica_users_find_by_handle.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas.",
inputSchema: { type: "object", properties: {} },
},
executor: this.getMyProfile.bind(this),
},
{
definition: {
name: "pica_users_set_bio",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "write",
description: "Use when the user asks: 'set my bio to X', 'change my profile description', 'update my bio'. " +
"Write your bio (plain text, max 280 chars). Server strips HTML/markdown and truncates. Empty string clears the bio. Returns the canonical stored value. " +
"To copy the biography already held on your linked catalog person, use pica_users_import_bio_from_person.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas.",
inputSchema: {
type: "object",
properties: {
bio: {
type: "string",
description: "Bio text. Pass empty string to clear. Max 280 chars (truncated server-side).",
},
},
required: ["bio"],
},
},
executor: this.setBio.bind(this),
},
{
definition: {
name: "pica_users_set_avatar_url",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "write",
description: "Use when the user asks: 'set my avatar to X URL', 'change my profile picture', 'update my avatar'. " +
"Write your avatar_url. URL must parse and match the trusted-host allowlist (Gravatar or PICA-managed S3). Pass null to clear. Returns the canonical stored value or `untrusted_avatar_host` on disallowed host. " +
"To change your bio text rather than your picture, use pica_users_set_bio.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas.",
inputSchema: {
type: "object",
properties: {
avatar_url: {
type: ["string", "null"],
description: "Avatar URL (https Gravatar or PICA S3), or null to clear.",
},
},
required: ["avatar_url"],
},
},
executor: this.setAvatarUrl.bind(this),
},
{
definition: {
name: "pica_users_import_bio_from_person",
scope: "write:people",
audiences: ["stdio", "http"],
tier: "write",
description: "Use when the user asks: 'pull my bio from my Spotify artist page', 'import bio from my catalog person', 'use the biography I already have on file'. " +
"Copy the biography held on your linked catalog person into your own profile bio. Guarded by `overwrite` (default false): refuses if you already have a bio and surfaces both bios so you can confirm before re-calling with overwrite=true. Returns no_source_available if you have no linked person or an empty biography. " +
"To write bio text directly rather than copy it, use pica_users_set_bio.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas.",
inputSchema: {
type: "object",
properties: {
overwrite: {
type: "boolean",
description: "Default false. Set true to overwrite an existing bio.",
},
},
},
},
executor: this.importBioFromPerson.bind(this),
},
];
}
async findByHandle(args) {
const result = await this.pica.users.findByHandle(args.handle);
if (result.found) {
return formatSuccess(`Found @${result.profile.handle}${result.profile.display_name ? ` — ${result.profile.display_name}` : ""}`, result);
}
return formatSuccess("No PICA user found with that handle.", result);
}
async setHandle(args) {
try {
const result = await this.pica.users.setHandle(args.handle);
return formatSuccess(`Handle claimed: @${result.handle}`, result);
}
catch (err) {
// T1c plan §5 (H3): the route answers 202 for a held handle, which the
// SDK raises as success:false. It is waiting, not refused.
if (err instanceof Error && /handle_held_for_review/.test(err.message)) {
return formatSuccess(`@${String(args.handle).toLowerCase()} is waiting for a check by the PICA team (it looks like a well-known name or a handle already taken). It shows as waiting in the person's settings. Don't retry.`, { held_for_review: true });
}
throw err;
}
}
async clearHandle() {
await this.pica.users.clearHandle();
return formatSuccess("Handle cleared.", { cleared: true });
}
async getMyProfile() {
const profile = await this.pica.users.getMyProfile();
return formatSuccess(profile.public_handle
? `Your handle is @${profile.public_handle}.`
: "You have not claimed a handle yet.", profile);
}
async setBio(args) {
const result = await this.pica.users.setBio(args.bio);
return formatSuccess(result.bio === null
? "Bio cleared."
: `Bio updated (${result.bio.length} chars).`, result);
}
async setAvatarUrl(args) {
const result = await this.pica.users.setAvatarUrl(args.avatar_url ?? null);
return formatSuccess(result.avatar_url === null
? "Avatar cleared."
: `Avatar updated to ${result.avatar_url}`, result);
}
async importBioFromPerson(args) {
const result = await this.pica.users.importBioFromPerson({
overwrite: args.overwrite === true,
});
return formatSuccess("Bio import completed.", result);
}
}
//# sourceMappingURL=users.js.map