@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
424 lines • 22.7 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { formatAsText, formatSuccess, MY_IDENTITY_WRITE_PROPERTIES, ORG_PROFILE_WRITE_PROPERTIES, } from "@withpica/mcp-utils";
export class SettingsTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
// NOTE: `pica_credits_history` was removed from the MCP surface 2026-04-22
// (see docs/MCP_REFINEMENT_LOG.md entry B-001). withPICA is free-runway →
// resident (ADR-276); deep processes are metered; surfacing a
// credit-transaction-history tool was billing-model drift.
// The SDK method `settings.creditsHistory()` is still available for internal
// telemetry.
return [
{
definition: {
name: "pica_storage_status",
scope: "read:files",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Check storage configuration and connection health — which cloud " +
"provider is active (PICA default, your own S3, GCS, Azure, R2, or " +
"Backblaze), bucket names, region, and the last-test result so the " +
"agent can say 'your S3 bucket failed its last health check' without " +
"re-running a probe. Never returns credentials. Pair with " +
"pica_storage_configure_start to set up BYOC, or pica_storage_disconnect " +
"to revert to PICA default.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {},
},
annotations: {
readOnlyHint: true,
idempotentHint: true,
},
},
executor: this.storageStatus.bind(this),
},
{
definition: {
name: "pica_organisation_profile",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Get your account profile — display name, tagline, IPI number, " +
"default currency, social links, and carbon tracking status.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {},
},
},
executor: this.orgProfile.bind(this),
},
{
definition: {
name: "pica_user_profile",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Use when the user asks: 'show my account info', 'what do you have on me?', 'my profile'. " +
"Get your user profile — name, email, role, creative types, " +
"AI disclosure defaults, and onboarding status. If " +
"identity.identifiers_unavailable is true, your IPI/IPN could " +
"not be read: they are unknown, not missing — don't offer to " +
"add one.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {},
},
},
executor: this.userProfile.bind(this),
},
{
definition: {
name: "pica_update_privacy_settings",
scope: "admin",
audiences: ["stdio", "public"],
tier: "write",
description: "Update your marketing / privacy preferences. Use this to unsubscribe " +
"from product emails — set receive_news_announcements=false and/or " +
"receive_monthly_digest=false. Writes the same fields the settings " +
"UI does; both flags default to true for new accounts. " +
"Subject is always the authenticated user (no user_id parameter " +
"accepted). To read the current flags before changing them, use " +
"pica_get_privacy_settings.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
receive_news_announcements: {
type: "boolean",
description: "Product announcements, drip sequences, weekly roundups, " +
"and all other non-transactional marketing email. " +
"Set to false to unsubscribe from everything.",
},
receive_monthly_digest: {
type: "boolean",
description: "Monthly catalog digest — a separate opt-out from the " +
"news/announcements flag. Set to false to stop receiving it.",
},
},
},
},
executor: this.updatePrivacySettings.bind(this),
},
{
definition: {
name: "pica_get_privacy_settings",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Read your marketing / privacy preferences. Returns the same flags " +
"the settings UI shows: receive_news_announcements and " +
"receive_monthly_digest. Use this to check current opt-in state " +
"before calling pica_update_privacy_settings. Subject is always " +
"the authenticated user (no user_id parameter accepted).",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {},
},
annotations: {
readOnlyHint: true,
idempotentHint: true,
},
},
executor: this.getPrivacySettings.bind(this),
},
{
definition: {
name: "pica_delete_my_account",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "destructive",
description: "Use when the user asks: 'delete my data', 'close my account', 'erase me'. " +
"Request deletion of your withPICA account. Creates a pending " +
"deletion request (same as the settings UI); nothing is erased " +
"until an org owner/admin or the PICA team confirms it, and then " +
"erasure is immediate and irreversible. No export is made " +
"automatically. Subject is always the " +
"authenticated user; no user_id parameter accepted. The agent " +
"is responsible for confirming with the user before invoking " +
"this tool.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
reason: {
type: "string",
description: "Optional free-text reason for leaving. Surfaced to the " +
"PICA team for product feedback; never shared externally.",
},
},
},
annotations: {
destructiveHint: true,
idempotentHint: false,
},
},
executor: this.deleteMyAccount.bind(this),
},
{
definition: {
name: "pica_update_my_identity",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Capture or update your own identity — full_name (your account name), " +
"stage_name, IPI, ISNI, IPN, PRO. " +
"Lazy-creates your person record if needed. This tool saves identifiers " +
"on your own record only; it does not link you to other orgs' records " +
"or grant access to their works. Returns the count of " +
"discoveries (credits / ownership claims / artist links) that landed " +
"for your org in the last 10 seconds. " +
"Subject is always the authenticated user; no user_id parameter " +
"accepted. To read what is already on file rather than write, use " +
"pica_user_profile; your public handle is set with " +
"pica_users_set_handle.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: MY_IDENTITY_WRITE_PROPERTIES,
},
annotations: {
idempotentHint: true,
},
},
executor: this.updateMyIdentity.bind(this),
},
{
definition: {
name: "pica_update_organisation_profile",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Update your account profile — name, org_type, display_name, tagline, " +
"ipi_number. Use 'name' to fix typos from signup (e.g., rename a placeholder " +
"like 'My Organization' to your real org name). Scope is bounded to the " +
"authenticated caller's org; the org id is derived from the auth context, " +
"never accepted as a parameter. To read the current profile fields " +
"(not name) first, use pica_organisation_profile.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: ORG_PROFILE_WRITE_PROPERTIES,
},
annotations: {
idempotentHint: true,
},
},
executor: this.updateOrganisationProfile.bind(this),
},
{
definition: {
name: "pica_export_my_data",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Use when the user asks: 'export my data', 'get all my data', 'download everything you have on me'. " +
"Request a GDPR Article 15 / 20 data export. Queues a background " +
"job and returns a job_id immediately; a signed download link is " +
"emailed to your withPICA account address when the export completes. " +
"Format is JSON by default (v1). Subject is always the " +
"authenticated user; no user_id parameter accepted. " +
"For the catalogue's AI consent paperwork rather than your own " +
"account data, use pica_export_ai_consent.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {},
},
annotations: {
idempotentHint: false,
},
},
executor: this.exportMyData.bind(this),
},
];
}
async storageStatus(_args) {
const result = await this.pica.settings.storageConfig();
const config = result?.data || result;
// ADR-217 — surface the enhanced shape to the agent. Never include
// credentials. The GET endpoint already masks secret fields and only
// returns the un-masked region; we read just the structured non-secret
// fields so a future credential leak in `config.credentials` cannot
// accidentally appear in tool output.
const provider = config?.provider || "pica_s3";
const isByoc = typeof config?.is_byoc === "boolean"
? config.is_byoc
: provider !== "pica_s3";
const lastTestStatus = config?.last_test_status ?? "untested";
const lastTestedAt = config?.last_tested_at ?? null;
const lastTestError = config?.last_test_error ?? null;
const region = config?.region ?? null;
const buckets = config?.buckets || { audio: "", media: "", documents: "" };
const summary = isByoc
? `Storage provider: ${provider} (your own bucket). Last test: ${lastTestStatus}` +
(lastTestStatus === "failed" && lastTestError
? ` — ${lastTestError}`
: "")
: `Storage provider: pica_s3 (PICA default — always available).`;
return formatSuccess(summary, {
provider,
is_byoc: isByoc,
buckets: {
audio: buckets.audio || "",
media: buckets.media || "",
documents: buckets.documents || "",
},
region,
last_tested_at: lastTestedAt,
last_test_status: lastTestStatus,
last_test_error: lastTestError,
});
}
async orgProfile(_args) {
const result = await this.pica.settings.orgProfile();
const org = result?.data || result;
return formatSuccess(`Organisation: ${org?.display_name || "unnamed"}`, org);
}
async userProfile(_args) {
const result = await this.pica.settings.userProfile();
const profile = result?.data || result;
return formatSuccess(`User: ${profile?.full_name || profile?.email || "unknown"}`, profile);
}
async updatePrivacySettings(args) {
// Only forward the two flags we expose — silently drops any other
// key the agent might pass so we don't accidentally PATCH
// unrelated profile fields through this tool.
const payload = {};
if (typeof args.receive_news_announcements === "boolean") {
payload.receive_news_announcements = args.receive_news_announcements;
}
if (typeof args.receive_monthly_digest === "boolean") {
payload.receive_monthly_digest = args.receive_monthly_digest;
}
if (Object.keys(payload).length === 0) {
return formatAsText("No change — pass receive_news_announcements and/or receive_monthly_digest.");
}
const result = await this.pica.settings.updatePrivacySettings(payload);
const updated = result?.data || result;
const flags = [
`receive_news_announcements: ${updated?.receive_news_announcements ?? "unchanged"}`,
`receive_monthly_digest: ${updated?.receive_monthly_digest ?? "unchanged"}`,
].join(", ");
return formatSuccess(`Privacy settings updated — ${flags}`, updated);
}
async getPrivacySettings(_args) {
const result = await this.pica.settings.getPrivacySettings();
const profile = result?.data || result;
// Only surface the flags the paired update tool writes. Keeps the
// get/update pair symmetric and prevents this tool from leaking
// unrelated profile fields (full_name, role, bio, etc.) to the agent.
const flags = {
receive_news_announcements: profile?.receive_news_announcements ?? null,
receive_monthly_digest: profile?.receive_monthly_digest ?? null,
};
const summary = [
`receive_news_announcements: ${flags.receive_news_announcements ?? "unset"}`,
`receive_monthly_digest: ${flags.receive_monthly_digest ?? "unset"}`,
].join(", ");
return formatSuccess(`Privacy settings — ${summary}`, flags);
}
async deleteMyAccount(args) {
// Defensively reject any caller-supplied user_id. ADR-184 rule 2 —
// the subject is always the authenticated user; accepting a user_id
// is a prompt-injection vector. The schema doesn't advertise it, but
// agents sometimes send speculative keys; ignore anything other than
// the documented `reason`.
const payload = {};
if (typeof args.reason === "string" && args.reason.trim()) {
payload.reason = args.reason.trim();
}
const result = await this.pica.gdpr.deleteMyAccount(payload);
const data = result?.data || result;
return formatSuccess(`Account deletion requested. Nothing is erased until the request is ` +
`reviewed and confirmed. No export is made automatically.`, data);
}
async exportMyData(_args) {
const result = await this.pica.gdpr.exportMyData();
const data = result?.data || result;
return formatSuccess(`Data export queued (job_id: ${data?.job_id ?? "unknown"}). ` +
`A signed download link will be emailed to your withPICA account address ` +
`when the job completes.`, data);
}
async updateMyIdentity(args) {
// ADR-184 rule 2 — silently drop any caller-supplied user_id. Only
// forward the documented identity fields so a speculative agent key
// can't leak data through this tool.
const payload = {};
for (const key of Object.keys(MY_IDENTITY_WRITE_PROPERTIES)) {
const value = args[key];
if (value === undefined)
continue;
if (value === null || typeof value === "string") {
payload[key] = value;
}
}
if (Object.keys(payload).length === 0) {
return formatAsText("No change — pass at least one of: full_name, stage_name, cae_ipi_number, isni, ipn, pro_name, pro_member_number.");
}
const result = await this.pica.settings.updateMyIdentity(payload);
const data = result?.data || result;
const updated = Array.isArray(data?.updated) ? data.updated : [];
const disc = data?.discoveries_added || {
credits: 0,
custody: 0,
artists: 0,
};
const fieldsLine = updated.length > 0
? `Updated: ${updated.join(", ")}.`
: "No fields changed.";
const discTotal = disc.credits + disc.custody + disc.artists;
// The update itself discovers nothing (identifiers stay on the caller's
// own record), so these are whatever landed for the org in the last 10
// seconds — never attributed to this call.
const discLine = discTotal > 0
? `${disc.credits} credits, ${disc.custody} ownership claims and ${disc.artists} artist links were discovered for your org in the last few seconds — call pica_discoveries_query to review.`
: "";
return formatSuccess(discLine ? `${fieldsLine} ${discLine}` : fieldsLine, data);
}
async updateOrganisationProfile(args) {
const payload = {};
for (const key of Object.keys(ORG_PROFILE_WRITE_PROPERTIES)) {
const value = args[key];
if (value === undefined)
continue;
if (value === null || typeof value === "string") {
payload[key] = value;
}
}
if (Object.keys(payload).length === 0) {
return formatAsText("No change — pass at least one of: org_type, display_name, tagline, ipi_number.");
}
const result = await this.pica.settings.updateOrganisationProfile(payload);
const data = result?.data || result;
const updated = Array.isArray(data?.updated) ? data.updated : [];
const msg = updated.length > 0
? `Organisation profile updated — ${updated.join(", ")}.`
: "No fields changed.";
return formatSuccess(msg, data);
}
}
//# sourceMappingURL=settings.js.map