UNPKG

@withpica/mcp-server

Version:

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

400 lines • 20.7 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /* eslint-disable @typescript-eslint/no-explicit-any -- ToolExecutor signature uses Record<string, any> */ /** * MCP App Tools — tools with _meta.ui for interactive iframe rendering. * * ADR-152: scope reduced to two generic cards (pica_upload, pica_download). * ADR-200 Phase 1: portable upload sessions for non-Apps clients. * - MCP Apps clients: render the iframe via _meta.ui.resourceUri (unchanged). * - Clients with capabilities.elicitation.url: receive an elicitation/create * url request pointing at the workspace upload page. * - All clients: response includes a `resource_link` content block as the * universal floor — every MCP client can render or open it. */ import { randomUUID } from "node:crypto"; import { ExportTools } from "./exports.js"; import { clientSupportsUrlElicitation } from "@withpica/mcp-utils"; const DEFAULT_PICA_API_URL = "https://withpica.com/api"; /** * Derive the user-facing web base URL from the configured MCP API URL. * `https://withpica.com/api` → `https://withpica.com`. * * Centralised so the elicitation URL and the resource_link uri agree. * ADR-200 Phase 1.5 will swap this for a per-session JWT-bound URL on * `connect.withpica.com/upload/<token>`. For now we point at the * existing authenticated workspace upload affordance — already shipped * behind session auth, no new auth surface required. */ function deriveWebBaseUrl() { const apiUrl = process.env.PICA_API_URL || DEFAULT_PICA_API_URL; // Strip trailing /api or /api/ — leaves the user-facing origin. const trimmed = apiUrl.replace(/\/api\/?$/, ""); return trimmed || apiUrl; } function formatBytes(bytes) { if (bytes == null || isNaN(bytes)) return ""; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } export class AppTools { pica; exportTools; constructor(pica) { this.pica = pica; this.exportTools = new ExportTools(pica); } getTools() { return [ { definition: { name: "pica_upload", scope: "write:files", audiences: ["stdio", "http", "public"], tier: "write", description: "Use when the user asks: 'can I upload audio from here?', 'upload the contract', 'upload some session photos', 'can I send you a song?', 'how do I upload from chat?'. " + "Open a file upload card for the user to drop files in. Use when the user " + "has files on their device that PICA needs — audio, photos, documents, stems, " + "session files, anything. Returns metadata for each uploaded file " + "(id, url, mime_type, size) so you can link them to works, recordings, " + "assets, agreements, or wherever they belong. " + "Portable across clients. MCP Apps " + "clients (Claude Desktop, Claude.ai web, Cursor, VS Code MCP) render the " + "iframe in-chat. Other clients (ChatGPT, Gemini) receive a resource_link " + "to a JWT-bound /upload/<token> URL scoped to the caller's org with a " + "10-minute TTL; clients advertising elicitation/create url additionally " + "receive a server-issued consent prompt to open it. After completion, " + "the agent learns about the new file via _meta.session_state.pending_uploads " + "decrementing and a discovery_events row surfacing on the next inspect call. " + "For programmatic uploads where you already have file details: " + "call pica_upload_file + pica_upload_complete.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { accept: { type: "string", enum: ["image", "audio", "video", "document", "any"], description: "Filter the file picker. 'audio' routes through pica_audio_presigned_upload " + "for automatic BPM/key analysis. Defaults to 'any'.", }, purpose: { type: "string", description: "Human-readable hint shown in the card heading (e.g. 'session stems', " + "'rights evidence', 'gear photos'). Does not affect how files are stored.", }, max_files: { type: "number", description: "Maximum number of files. Defaults to unlimited.", }, }, }, _meta: { ui: { resourceUri: "ui://pica/upload" }, }, }, executor: this.upload.bind(this), }, { definition: { name: "pica_download", scope: "read:files", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'send me the export', 'download that file', 'give me the file'. " + "Open a download card for a file PICA has produced. Use after any export " + "tool (pica_export_catalog_csv, pica_export_catalog_asset_report, " + "pica_export_industry_ready, pica_export_song_registration, pica_export_ai_consent, " + "etc.) to deliver the file directly to the user's device. The card shows file " + "name, size, type, and a download button. " + "Portable across clients. MCP Apps clients render the iframe; " + "other clients receive a `resource_link` content block pointing at the signed URL " + "alongside the JSON envelope so the agent can present a normal click-to-download.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { url: { type: "string", description: "Presigned download URL (required)", }, filename: { type: "string", description: "Suggested filename for the download (required)", }, mime_type: { type: "string", description: "MIME type (e.g. 'application/pdf', 'text/csv')", }, size: { type: "number", description: "File size in bytes, if known", }, description: { type: "string", description: "Short description of what the file contains", }, expires_at: { type: "string", description: "ISO timestamp when the presigned URL expires. Card warns user if download isn't started before then.", }, }, required: ["url", "filename"], }, _meta: { ui: { resourceUri: "ui://pica/download" }, }, }, executor: this.download.bind(this), }, { definition: { name: "pica_car_preview", // It runs the CAR export: an S3 object and a pica_exports row, behind // the /api/admin/exports/* write floor. Declaring read:catalog/read // advertised a read-only tool that writes and could not pass the proxy. scope: "write:files", audiences: ["stdio", "http", "public"], tier: "write", description: "Generate a Catalog Asset Report (CAR) PDF/ZIP and deliver it via the download card. " + "Thin orchestration wrapper around pica_export_catalog_asset_report — runs the same " + "export then returns a download-shaped payload that renders " + "in the ui://pica/download card. " + "IMPORTANT: the report is UNSIGNED whatever the format. Its declaration can only be " + "attested by someone signed in to PICA, from the export panel; a confirmation in " + "conversation is not an attestation. Do not tell the user the report is signed.", 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: { format: { type: "string", enum: ["assessment", "lending"], description: "A label for the download card: 'assessment' (default) or 'lending'. It does " + "not change the report, which is unsigned either way.", }, sections: { type: "object", description: "Which sections to include. Ownership is always included. Same shape as " + "pica_export_catalog_asset_report.", properties: { valuation: { type: "boolean", description: "Include financial valuation section", }, assets: { type: "boolean", description: "Include multimedia and production assets section", }, agreements: { type: "boolean", description: "Include agreements and licensing section", }, intelligence: { type: "boolean", description: "Include market intelligence section", }, }, }, }, }, _meta: { ui: { resourceUri: "ui://pica/download" }, }, }, executor: this.carPreview.bind(this), }, ]; } async carPreview(args) { const format = args.format ?? "assessment"; // Reuse the executor from ExportTools so the export's behaviour stays in // one place. const carTool = this.exportTools .getTools() .find((t) => t.definition.name === "pica_export_catalog_asset_report"); if (!carTool) { throw new Error("pica_export_catalog_asset_report not found — ExportTools wiring changed"); } // Credit-gate refusal throws before returning, so any non-throwing result // here is a success envelope from formatSuccess(summary, data). const carResult = await carTool.executor({ sections: args.sections }); let data = null; try { const text = carResult.content?.[0]?.text ?? ""; const parsed = JSON.parse(text); data = parsed?.data ?? parsed; } catch { data = null; } if (!data || (!data.download_url && !data.url)) { // Success envelope without a URL means the underlying tool changed its // output shape — fail loudly instead of silently leaking the raw result. throw new Error("pica_car_preview: underlying export returned success without a download URL"); } const url = data.download_url ?? data.url; const filename = data.filename ?? (url.split("/").pop()?.split("?")[0] || "catalog-asset-report.pdf"); const size = data.file_size_bytes ?? data.size ?? null; const mime_type = data.mime_type ?? (filename.endsWith(".zip") ? "application/zip" : "application/pdf"); const expires_at = data.expires_at ?? null; const workCount = data.work_count ?? data.works_included ?? null; const description = `catalog asset report${workCount ? ` — ${workCount} works` : ""}, ${format} format`; const sizeLabel = size != null ? ` (${formatBytes(size)})` : ""; const text_summary = `catalog asset report ready: ${filename}${sizeLabel}\nformat: ${format}\nnot attested: only someone signed in to pica can sign it, from the export panel\ndirect link: ${url}`; return { content: [ { type: "text", text: JSON.stringify({ text_summary, file: { url, filename, mime_type, size, description, expires_at, }, }), }, ], }; } async upload(args, ctx) { const accept = args.accept ?? "any"; const purpose = args.purpose ?? null; const max_files = args.max_files ?? null; // ADR-208 Phase 2 (Path B per § Primitive C amendment, commit 5fef3204d): // server-side JWT mint via pica.uploads.createSession. The PICA backend // resolves the caller's org/user from auth, mints an HS256 JWT bound // to {orgId, userId, purpose, acceptFilter, exp ≈ now + 10min}, // atomically increments organisations.pending_uploads_count, and // returns the URL. The signing secret never leaves the backend; the // S3 storage key is computed at upload time from claims.orgId so a // valid signature attests to the org binding without needing a // tamper-target `bucketKey` claim. // // Fail-soft: if createSession fails (network blip, route 5xx, etc.) we // fall back to the legacy workspace URL so the user can still upload // manually. The agent surfaces the failure in textSummary. let uploadUrl; let sessionError = null; try { const session = await this.pica.uploads.createSession({ purpose, acceptFilter: accept, maxFiles: max_files, }); uploadUrl = session.url; } catch (err) { sessionError = err instanceof Error ? err.message : "upload session mint failed"; uploadUrl = `${deriveWebBaseUrl()}/workspace`; } const acceptLabel = accept === "any" ? "files" : `${accept} file${accept === "audio" ? "s" : "s"}`; const textSummary = sessionError ? `ready to receive ${acceptLabel}. Couldn't mint a scoped upload session (${sessionError}); falling back to ${uploadUrl} — please open the workspace and upload manually.` : `ready to receive ${acceptLabel} — drop them here in a PICA Apps client, or open ${uploadUrl} to upload from a browser. Link expires in 10 minutes.`; // Universal floor: every MCP client can show a resource_link, so it // ships in every response regardless of capability detection. The // text envelope keeps the existing config payload for MCP Apps // clients that read it from `content[0].text`. const resourceLink = { type: "resource_link", uri: uploadUrl, name: purpose ? `Upload ${purpose}` : "Upload to PICA", description: accept === "any" ? "Open the PICA workspace to drop files into your catalog." : `Open the PICA workspace to drop ${accept} files into your catalog.`, mimeType: "text/html", }; const baseContent = [ { type: "text", text: JSON.stringify({ text_summary: textSummary, config: { accept, purpose, max_files }, upload_url: uploadUrl, }), }, resourceLink, ]; // Headline path: clients that advertise `capabilities.elicitation.url` // get a server-issued elicitation/create request pointing at the // upload page. The capability gate (ADR-200 helper) is mandatory — // calling elicitInput against a non-supporter throws synchronously. if (clientSupportsUrlElicitation(ctx?.server)) { try { await ctx.server.elicitInput({ mode: "url", url: uploadUrl, elicitationId: `pica-upload-${randomUUID()}`, message: purpose != null ? `Open this URL to upload ${purpose}.` : "Open this URL to upload files to PICA.", }, { timeout: 60_000 }); } catch { // The user declined / cancelled / the elicitation timed out. // The resource_link in the response below remains usable, so // we degrade silently rather than fail the tool. } } return { content: baseContent }; } async download(args) { const url = args.url; const filename = args.filename; const mime_type = args.mime_type ?? "application/octet-stream"; const size = args.size ?? null; const description = args.description ?? null; const expires_at = args.expires_at ?? null; const sizeLabel = size != null ? ` (${formatBytes(size)})` : ""; const textSummary = `download ready: ${filename}${sizeLabel}\ndirect link: ${url}`; // ADR-200 Phase 1 universal floor: the signed download URL ships as a // resource_link content block alongside the existing text envelope. // Every MCP client can render or open it; MCP Apps clients still // pick up the iframe via _meta.ui.resourceUri unchanged. return { content: [ { type: "text", text: JSON.stringify({ text_summary: textSummary, file: { url, filename, mime_type, size, description, expires_at, }, }), }, { type: "resource_link", uri: url, name: filename, description: description ?? `Download ${filename}`, mimeType: mime_type, }, ], }; } } //# sourceMappingURL=app-tools.js.map