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