UNPKG

@withpica/mcp-server

Version:

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

480 lines (450 loc) • 15.2 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { INSTRUMENT_SERIF_REGULAR_BASE64, INSTRUMENT_SERIF_ITALIC_BASE64, GEIST_SANS_LIGHT_BASE64, APP_BRIDGE_JS, } from "./generated/shared-bundle.js"; // --------------------------------------------------------------------------- // Design system CSS — embedded fonts + full PICA dark/light theme // --------------------------------------------------------------------------- export const PICA_APP_CSS = ` /* ---- Fonts ---- */ @font-face { font-family: 'Instrument Serif'; font-style: normal; font-weight: 400; src: url('data:font/woff2;base64,${INSTRUMENT_SERIF_REGULAR_BASE64}') format('woff2'); } @font-face { font-family: 'Instrument Serif'; font-style: italic; font-weight: 400; src: url('data:font/woff2;base64,${INSTRUMENT_SERIF_ITALIC_BASE64}') format('woff2'); } @font-face { font-family: 'Geist Sans'; font-style: normal; font-weight: 300; src: url('data:font/woff2;base64,${GEIST_SANS_LIGHT_BASE64}') format('woff2'); } /* ---- Reset ---- */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html { font-size: 16px; -webkit-font-smoothing: antialiased; } body { font-family: 'Geist Sans', system-ui, sans-serif; font-weight: 300; } a { color: inherit; text-decoration: none; } button { cursor: pointer; border: none; background: none; font-family: inherit; } ul, ol { list-style: none; } img, svg { display: block; max-width: 100%; } /* ---- CSS Custom Properties: dark theme (default) ---- */ [data-theme="dark"], :root { --bg: #0A0A0A; --card-bg: rgba(255, 255, 255, 0.05); --card-border: rgba(255, 255, 255, 0.10); --text-primary: rgba(255, 255, 255, 1); --text-secondary: rgba(255, 255, 255, 0.40); --text-tertiary: rgba(255, 255, 255, 0.25); --accent: #B87333; --ghost-bg: rgba(255, 255, 255, 0.05); --ghost-border: rgba(255, 255, 255, 0.07); } /* ---- CSS Custom Properties: light theme ---- */ [data-theme="light"] { --bg: #F5F4F1; --card-bg: rgba(0, 0, 0, 0.04); --card-border: rgba(0, 0, 0, 0.10); --text-primary: rgba(0, 0, 0, 0.90); --text-secondary: rgba(0, 0, 0, 0.50); --text-tertiary: rgba(0, 0, 0, 0.35); --accent: #B87333; --ghost-bg: rgba(0, 0, 0, 0.04); --ghost-border: rgba(0, 0, 0, 0.07); } /* ---- Base ---- */ body { background: var(--bg); color: var(--text-primary); min-height: 100vh; } /* ---- Typography ---- */ .heading, .heading-lg, .heading-md, .heading-sm { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; color: var(--text-primary); letter-spacing: 0; } .heading-lg { font-size: 2rem; line-height: 1.15; } .heading-md { font-size: 1.5rem; line-height: 1.2; } .heading-sm { font-size: 1.125rem; line-height: 1.3; } .heading { font-size: 1.75rem; line-height: 1.2; } .text-secondary { color: var(--text-secondary); } .text-tertiary { color: var(--text-tertiary); } .text-accent { color: var(--accent); } .label { font-family: 'Geist Sans', system-ui, sans-serif; font-size: 0.6875rem; font-weight: 300; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary); } /* ---- Card ---- */ .card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 12px; padding: 24px; backdrop-filter: blur(16px); } .card-flat { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 12px; padding: 24px; } /* ---- Ghost pill button ---- */ .btn-ghost { display: inline-flex; align-items: center; gap: 6px; background: var(--ghost-bg); border: 1px solid var(--ghost-border); border-radius: 9999px; padding: 8px 20px; font-size: 0.9375rem; font-weight: 300; color: var(--text-secondary); transition: background 0.15s, border-color 0.15s, color 0.15s; } .btn-ghost:hover { background: rgba(184, 115, 51, 0.10); border-color: rgba(184, 115, 51, 0.30); color: var(--accent); } .btn-copper { display: inline-flex; align-items: center; gap: 6px; background: var(--accent); border: 1px solid transparent; border-radius: 9999px; padding: 8px 20px; font-size: 0.9375rem; font-weight: 300; color: #0A0A0A; transition: opacity 0.15s; } .btn-copper:hover { opacity: 0.85; } /* ---- Badge ---- */ .badge { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 9999px; font-size: 0.75rem; font-weight: 300; background: var(--card-bg); border: 1px solid var(--card-border); color: var(--text-secondary); } .badge-copper { background: rgba(184, 115, 51, 0.15); border-color: rgba(184, 115, 51, 0.30); color: var(--accent); } /* ---- Health bar ---- */ .health-bar { display: flex; height: 6px; border-radius: 9999px; overflow: hidden; gap: 2px; } .health-bar__complete { background: #87A96B; } .health-bar__attention { background: var(--accent); } .health-bar__incomplete { background: var(--card-border); flex: 1; } /* ---- Layout helpers ---- */ .stack { display: flex; flex-direction: column; } .gap-4 { gap: 1rem; } .gap-6 { gap: 1.5rem; } .gap-8 { gap: 2rem; } .row { display: flex; flex-direction: row; align-items: center; } .between { justify-content: space-between; } .section { padding: 24px 0; border-top: 1px solid var(--card-border); } .section:first-child { border-top: none; } /* ---- Clickable / collapsible ---- */ .clickable { cursor: pointer; transition: opacity 0.15s; user-select: none; } .clickable:hover { opacity: 0.80; } [data-collapsed] .collapsible-content { display: none; } /* ---- Responsive ---- */ @media (max-width: 500px) { .heading-lg { font-size: 1.5rem; } .heading { font-size: 1.375rem; } .card, .card-flat { padding: 16px; } } `; // --------------------------------------------------------------------------- // Shared JS foundation — runs inside every iframe // --------------------------------------------------------------------------- export const PICA_APP_JS = ` (function () { 'use strict'; // Initialise bridge from injected ext-apps runtime var app = (window.__mcpExtApps && window.__mcpExtApps.getInstance) ? window.__mcpExtApps.getInstance() : null; // Keep theme in sync with host window.addEventListener('hostcontextchanged', function (e) { var detail = e.detail || {}; if (detail.theme) { document.documentElement.setAttribute('data-theme', detail.theme); } }); // Dispatch tool results to card handlers via ontoolresult callback window.addEventListener('toolresult', function (e) { var raw = (e.detail && e.detail.result) ? e.detail.result : null; if (!raw) return; var data; try { data = (typeof raw === 'string') ? JSON.parse(raw) : raw; } catch (_) { data = raw; } if (typeof window.onPicaToolResult === 'function') { window.onPicaToolResult(data); } }); // Public API: send a message to the host agent via sendMessage window.picaAgentAction = function (message) { if (app && typeof app.sendMessage === 'function') { return app.sendMessage(message); } console.warn('[pica] bridge not available — message not sent:', message); }; // Parse ADR-149 error envelope window.picaParseError = function (err) { if (!err) return null; if (err && typeof err === 'object' && 'code' in err) { return { code: err.code, message: err.message || 'unknown error', data: err.data || null }; } return { code: -32603, message: String(err), data: null }; }; })(); `; // --------------------------------------------------------------------------- // Upload manager — reusable per-file state machine for card file uploads. // Exposed as an ES5 JS string snippet that cards embed in their own script // block. Instantiate with createUploadManager({ accept, onStateChange, app }). // // State machine per file: pending -> uploading -> uploaded | failed | network_error // Routes audio uploads through pica_audio_presigned_upload + pica_audio_complete_upload; // all other types go through pica_upload_file + pica_upload_complete. // // Methods returned: addFiles(FileList), removeFile(id), retryFile(id), getFiles() // --------------------------------------------------------------------------- export const PICA_UPLOAD_MANAGER_JS = ` function createUploadManager(config) { config = config || {}; var accept = config.accept || "any"; var onStateChange = config.onStateChange || function () {}; var app = config.app; var files = []; var idCounter = 0; function notify() { try { onStateChange(files.slice()); } catch (_) {} } function findFile(id) { for (var i = 0; i < files.length; i++) { if (files[i].id === id) return files[i]; } return null; } function setState(id, state, extra) { var f = findFile(id); if (!f) return; f.state = state; if (extra && extra.url) f.url = extra.url; if (extra && extra.meta) { f.mime_type = extra.meta.mime_type; f.size = extra.meta.size; f.uploaded_at = extra.meta.uploaded_at; f.upload_id = extra.meta.upload_id; } if (extra && extra.error) f.error = extra.error; notify(); } function doUpload(fileRecord) { var file = fileRecord.file; var id = fileRecord.id; var isAudio = accept === "audio"; var presignTool = isAudio ? "pica_audio_presigned_upload" : "pica_upload_file"; var completeTool = isAudio ? "pica_audio_complete_upload" : "pica_upload_complete"; app.callServerTool({ name: presignTool, arguments: { filename: file.name, content_type: file.type, file_size: file.size, }, }).then(function (presignResult) { if (presignResult && presignResult.isError) throw new Error("presign failed"); var presign; try { presign = JSON.parse(presignResult.content[0].text); } catch (_) { throw new Error("presign parse failed"); } var presignData = presign.data || presign; return fetch(presignData.upload_url, { method: "PUT", body: file }) .then(function (res) { if (!res.ok) throw new Error("upload failed: " + res.status); return presignData; }) .catch(function (netErr) { setState(id, "network_error", { error: netErr && netErr.message }); throw new Error("__network_error__"); }); }).then(function (presignData) { return app.callServerTool({ name: completeTool, arguments: { upload_id: presignData.upload_id, key: presignData.key, bucket: presignData.bucket, storage_category: presignData.storage_category, filename: file.name, content_type: file.type, file_size: file.size, }, }); }).then(function (completeResult) { if (completeResult && completeResult.isError) throw new Error("complete failed"); var completed; try { completed = JSON.parse(completeResult.content[0].text); } catch (_) { throw new Error("complete parse failed"); } var completedData = completed.data || completed; setState(id, "uploaded", { url: completedData.url, meta: { mime_type: file.type, size: file.size, uploaded_at: new Date().toISOString(), upload_id: completedData.id || completedData.upload_id || null, }, }); }).catch(function (err) { if (err && err.message === "__network_error__") return; setState(id, "failed", { error: err && err.message }); }); } function addFiles(fileList) { var added = []; for (var i = 0; i < fileList.length; i++) { var file = fileList[i]; var id = "f-" + (++idCounter); var record = { id: id, state: "pending", file: file, filename: file.name, mime_type: file.type, size: file.size, url: null, }; files.push(record); added.push(record); } notify(); added.forEach(function (r) { r.state = "uploading"; notify(); doUpload(r); }); } function removeFile(id) { files = files.filter(function (f) { return f.id !== id; }); notify(); } function retryFile(id) { var f = findFile(id); if (!f || !f.file) return; f.state = "uploading"; f.error = null; notify(); doUpload(f); } function getFiles() { return files.slice(); } return { addFiles: addFiles, removeFile: removeFile, retryFile: retryFile, getFiles: getFiles, }; } `; // --------------------------------------------------------------------------- // HTML builder — assembles a complete iframe document // --------------------------------------------------------------------------- export function buildAppHtml(title, bodyHtml, appJs) { return `<!DOCTYPE html> <html data-theme="dark"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>${title}</title> <style>${PICA_APP_CSS}</style> </head> <body> ${bodyHtml} <script>${APP_BRIDGE_JS}</script> <script>${PICA_APP_JS}</script> <script>${PICA_UPLOAD_MANAGER_JS}</script> <script>${appJs}</script> </body> </html>`; } /** * CSP connect-src allowlist for MCP App cards that upload files to storage. * Covers PICA-hosted media buckets and common BYOC (bring your own cloud) * providers: AWS S3, Cloudflare R2, Wasabi, Backblaze B2. * * Hosts that honor wildcard entries get broader coverage. Hosts that don't * still work for PICA defaults (exact entries first) and specific BYOC regions. * Users on unlisted endpoints see a graceful "use web dashboard" fallback. */ export const PICA_STORAGE_CONNECT_DOMAINS = [ // PICA-hosted media bucket "https://withpica-media-production.s3.amazonaws.com", "https://withpica-media-production.s3.us-east-1.amazonaws.com", "https://withpica-media-production.s3.eu-north-1.amazonaws.com", // AWS S3 BYOC — common region endpoints (virtual-hosted style) "https://*.s3.amazonaws.com", "https://*.s3.us-east-1.amazonaws.com", "https://*.s3.us-east-2.amazonaws.com", "https://*.s3.us-west-1.amazonaws.com", "https://*.s3.us-west-2.amazonaws.com", "https://*.s3.eu-west-1.amazonaws.com", "https://*.s3.eu-west-2.amazonaws.com", "https://*.s3.eu-central-1.amazonaws.com", "https://*.s3.eu-north-1.amazonaws.com", "https://*.s3.ap-southeast-1.amazonaws.com", "https://*.s3.ap-southeast-2.amazonaws.com", "https://*.s3.ap-northeast-1.amazonaws.com", // Cloudflare R2 "https://*.r2.cloudflarestorage.com", // Wasabi "https://s3.wasabisys.com", "https://s3.us-east-1.wasabisys.com", "https://s3.us-east-2.wasabisys.com", "https://s3.us-west-1.wasabisys.com", "https://s3.eu-central-1.wasabisys.com", "https://s3.ap-northeast-1.wasabisys.com", // Backblaze B2 S3-compatible "https://s3.us-west-000.backblazeb2.com", "https://s3.us-west-001.backblazeb2.com", "https://s3.us-west-002.backblazeb2.com", "https://s3.us-west-004.backblazeb2.com", "https://s3.eu-central-003.backblazeb2.com", ]; //# sourceMappingURL=shared.js.map