UNPKG

@withpica/mcp-server

Version:

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

421 lines • 22.4 kB
// 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.", 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