@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
257 lines • 12 kB
JavaScript
// 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