UNPKG

@withpica/mcp-server

Version:

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

257 lines • 12 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /** * Storage Configuration Tools — ADR-217 Primitives B + C. * * `pica_storage_status` (Primitive A) lives in `settings.ts` (ADR-184/189 * settings cluster — kept there to avoid churn). This file owns the two * mutating verbs: * * B — `pica_storage_configure_start` — mints a JWT-bound URL pointing at * `/storage/configure/<token>` where the user pastes credentials. Same * JWT-URL pattern as ADR-208 P2 upload + ADR-211 P1 signup. * * C — `pica_storage_disconnect` — reverts org back to PICA-managed default. * Destructive (future uploads route differently); two-step confirmation * handled by the destructive-tool gate in index.ts via * `risk: "destructive"` in metadata.ts. Does NOT delete files in the * BYOC bucket; only removes the org's pointer (idempotent). * * Both tools call public backend endpoints to mint / mutate; the * `STORAGE_CONFIG_TOKEN_SECRET` never leaves the backend so customer- * distributed binaries (`npx @withpica/mcp-server`) cannot mint tokens * locally. */ import { randomUUID } from "node:crypto"; import { clientSupportsUrlElicitation } from "@withpica/mcp-utils"; const VALID_PROVIDERS = ["s3", "gcs", "azure", "r2", "backblaze"]; export class StorageConfigTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_storage_configure_start", scope: "admin", audiences: ["stdio"], tier: "write", description: "Set up your own cloud storage bucket (BYOC) instead of PICA's default — " + "supports AWS S3, Google Cloud Storage, Azure Blob, Cloudflare R2, " + "and Backblaze B2. Mints a single-use configure link valid for 15 minutes; " + "surface the URL to the user as a clickable resource_link. The link opens " + "a page that collects bucket names + credentials, runs a real PUT/HEAD/DELETE " + "test against your bucket, and only saves on success. Future uploads route " + "to the configured bucket; existing files stay where they are. Use " + "pica_storage_disconnect to revert to PICA default.", workflows: "infrastructure", vernacular_kind: "internal", vernacular_reason: "First step of the OAuth-style external-storage connect flow; the dispatcher chains through the redirect handler.", inputSchema: { type: "object", properties: { provider: { type: "string", enum: [...VALID_PROVIDERS], description: "The storage provider you want to connect. Use pica_storage_disconnect " + "to revert to PICA-managed (pica_s3) — that's not a valid value here.", }, purpose: { type: "string", description: "Optional free-text — why the user is configuring BYOC " + "(e.g. 'compliance requires data in eu-west-1', 'consolidate billing'). " + "Captured for product analytics. Not surfaced to the user.", }, }, required: ["provider"], }, annotations: { readOnlyHint: false, destructiveHint: false, }, }, executor: this.configureStart.bind(this), }, { definition: { name: "pica_storage_disconnect", scope: "admin", audiences: ["stdio"], tier: "destructive", description: "Revert your storage to PICA-managed default. Future uploads will route " + "to PICA's buckets; existing files in your BYOC bucket are NOT deleted — " + "they remain in your bucket and continue to be accessible by your URLs. " + "Two-step destructive: the first call returns a confirmation token; the " + "second call with the token executes. Idempotent — calling on an already-" + "PICA-managed org is a no-op. Check what is configured first with " + "pica_storage_status; to point at your own bucket instead, use " + "pica_storage_configure_start.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Destructive operation; invoked by exact name after explicit user confirmation in a specialist flow.", inputSchema: { type: "object", properties: { confirmation_token: { type: "string", description: "Token returned by the first call. Pass it back to confirm. " + "If omitted, the tool returns confirmation_required + a fresh " + "token. The token is validated automatically before the " + "disconnect runs.", }, }, }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, }, }, executor: this.disconnect.bind(this), }, ]; } async configureStart(args, ctx) { const provider = typeof args.provider === "string" ? args.provider : ""; if (!VALID_PROVIDERS.includes(provider)) { return { content: [ { type: "text", text: `provider must be one of: ${VALID_PROVIDERS.join(", ")}.`, }, ], isError: true, }; } const purpose = typeof args.purpose === "string" && args.purpose.trim().length > 0 ? args.purpose.trim() : undefined; // Calls the public mint endpoint. Same shape as // pica_signup_start → /api/public/onboarding/signup-start. let response; try { response = (await this.pica.request("POST", "/admin/settings/storage/configure-start", purpose ? { provider, purpose } : { provider })); } catch (err) { const message = err instanceof Error ? err.message : "network error"; return { content: [ { type: "text", text: `couldn't mint your configure link (${message}). check your connection and try again.`, }, ], isError: true, }; } if (!response?.url || !response?.expires_at) { return { content: [ { type: "text", text: "the configure endpoint returned an incomplete response. please try again.", }, ], isError: true, }; } const url = response.url; const expiresAt = response.expires_at; const textSummary = `configure link minted for ${provider}. surface this URL to the user — ` + `they should click it to paste their bucket names and credentials. ` + `the link expires at ${expiresAt} (15 minutes from now). after they ` + `submit, we run a real test (PUT + HEAD + DELETE on each bucket) and ` + `only save on success.`; const resourceLink = { type: "resource_link", uri: url, name: `Configure ${provider} storage`, description: "Open this link to enter your bucket names and credentials. " + "We test the connection before saving.", mimeType: "text/html", }; const baseContent = [ { type: "text", text: JSON.stringify({ text_summary: textSummary, configure_url: url, expires_at: expiresAt, provider, surface: response.surface ?? "storage-config", }), }, resourceLink, ]; // Capability-gated url elicitation per // `feedback_mcp_sdk_capability_gates_elicitation.md` — the SDK throws // synchronously if the client doesn't advertise `elicitation.url`. if (clientSupportsUrlElicitation(ctx?.server)) { try { await ctx.server.elicitInput({ mode: "url", url, elicitationId: `pica-storage-config-${randomUUID()}`, message: `Open this URL to configure ${provider} storage. Expires at ${expiresAt}.`, }, { timeout: 60_000 }); } catch { // user declined / cancelled / timed out — `resource_link` remains // usable so we degrade silently rather than fail the tool. } } return { content: baseContent, structuredContent: { kind: "pica.card.storage_configure_start", version: 1, data: { configure_url: url, expires_at: expiresAt, provider, }, }, }; } async disconnect(args, _ctx) { // Note: the destructive-tool gate in index.ts validates // `confirmation_token` before this executor runs. By the time we're // here, the token has been consumed and we can perform the disconnect. void args; let response; try { response = (await this.pica.request("POST", "/admin/settings/storage/disconnect", {})); } catch (err) { const message = err instanceof Error ? err.message : "network error"; return { content: [ { type: "text", text: `couldn't disconnect (${message}). please try again.`, }, ], isError: true, }; } const previousProvider = response?.previous_provider ?? "unknown"; const summary = previousProvider === "pica_s3" || previousProvider === "unknown" ? "you were already on PICA-managed storage — no change." : `disconnected ${previousProvider}. future uploads route to PICA-managed storage. ` + `your existing files in the ${previousProvider} bucket are not deleted.`; return { content: [{ type: "text", text: summary }], structuredContent: { kind: "pica.card.storage_disconnected", version: 1, data: { previous_provider: previousProvider, current_provider: "pica_s3", }, }, }; } } //# sourceMappingURL=storage-config.js.map