UNPKG

@withpica/mcp-server

Version:

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

254 lines • 9.88 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. // NOTE (ADR-248): this tool's name is declared via a const rather than an // inline literal; PR B's registry-derived parity check resolves const names so // it is not a scanner blind spot. It was the 5th latent HTTP casualty // (registered, on stdio + public, never added to SCOPE_TOOL_MAP → invisible on // the authenticated connector despite being a safe read tool — strictly less // sensitive than pica_releases_inspect, which IS on the connector). The "http" // audience below is the deliberate fix so the connector can render releases. const TOOL_NAME = "pica_release_show"; const RESOURCE_URI = "ui://pica/release"; export class ReleaseRichTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: TOOL_NAME, scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'show me [release]', 'what's on [release]?', 'render this release'. " + "Render a release as a rich card — cover art image + markdown summary " + "(title, year, primary artists, label, tracklist with ISRCs and durations). " + "For pure data-shape reasoning use pica_releases_inspect instead — this tool is " + "shaped for user display, not agent computation. " + "Returns an image content block (cover art) alongside text. MCP " + "Apps clients (Claude Desktop, Cursor) get the iframe via " + "_meta.ui.resourceUri; every other client renders the universal " + "floor (image + text).", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { release_id: { type: "string", description: "UUID of the release to render.", }, }, required: ["release_id"], }, annotations: { readOnlyHint: true, }, _meta: { ui: { resourceUri: RESOURCE_URI }, }, }, executor: this.show.bind(this), }, ]; } async show(args) { const releaseId = args?.release_id; if (typeof releaseId !== "string" || releaseId.length === 0) { return errorResult("release_id is required. Pass the UUID of the release to render. " + "Try pica_releases_query to find the id you want."); } let release; try { release = (await this.pica.releases.get(releaseId)); } catch (error) { return errorResult(`couldn't load release ${releaseId}: ${errorMessage(error)}. ` + "Try pica_releases_query to confirm the id, or pica_search_all to find it by title."); } if (!release || !release.id) { return errorResult(`release ${releaseId} not found in this catalog. ` + "Try pica_releases_query to see what exists."); } let tracks = []; try { const trackResult = (await this.pica.releases.listTracks(releaseId)); if (Array.isArray(trackResult)) { tracks = trackResult; } else if (trackResult && Array.isArray(trackResult.data)) { tracks = trackResult.data; } } catch { // best-effort — track fetch is non-fatal for a rich-render card. // ADR-224's section-error contract is for inspect tools (where // the agent reads the data); rich-render's contract is "show // whatever's available," so fallback-to-empty is the right shape. tracks = []; } let cover = { has_artwork: false }; try { cover = (await this.pica.releases.getCoverArtThumbnail(releaseId)); } catch { // best-effort — cover art is rendering-only; absence renders a // text-only card without failing the whole tool. cover = { has_artwork: false }; } let labelName = null; if (release.label_organization_id) { labelName = await this.resolveLabelName(release.label_organization_id); } const markdown = buildMarkdown({ release, tracks, labelName }); const content = []; if (cover.has_artwork) { content.push({ type: "image", data: cover.base64, mimeType: cover.mime_type, }); } content.push({ type: "text", text: markdown }); return { content, structuredContent: { title: release.title, markdown, cover_base64: cover.has_artwork ? cover.base64 : null, cover_mime_type: cover.has_artwork ? cover.mime_type : null, // resource_link_uri is null in Phase 3; reserved for Phase 3.5 // when a release detail page exists. resource_link_uri: null, }, }; } async resolveLabelName(orgId) { try { // A lookup by id. It used to read one page of 200 party orgs and // search it, so a label past that page came back nameless (row-cap T3). const page = await this.pica.labels.listPage({ ids: [orgId], limit: 1 }); const match = page.data.find((l) => l.id === orgId); return match?.display_name ?? match?.name ?? null; } catch { return null; } } } function buildMarkdown(input) { const { release, tracks, labelName } = input; const headerLines = []; headerLines.push(`# ${release.title}`); const subtitleParts = []; if (release.release_type) subtitleParts.push(release.release_type); const year = extractYear(release.release_date); if (year) subtitleParts.push(year); if (subtitleParts.length > 0) { headerLines.push(`*${subtitleParts.join(" · ")}*`); } const metaLines = []; const primaryArtists = collectPrimaryArtists(tracks); if (primaryArtists.length > 0) { metaLines.push(`**artists**: ${primaryArtists.join(", ")}`); } if (labelName) { metaLines.push(`**label**: ${labelName}`); } if (release.upc) { metaLines.push(`**upc**: ${release.upc}`); } if (release.catalog_number) { metaLines.push(`**catalog #**: ${release.catalog_number}`); } const tracklistLines = []; if (tracks.length > 0) { tracklistLines.push(""); tracklistLines.push("## tracks"); for (const track of tracks) { tracklistLines.push(formatTrackLine(track)); } } else { tracklistLines.push(""); tracklistLines.push("*no tracks attached yet — use pica_releases_attach_track or pica_releases_create_with_tracks to add them.*"); } const sections = [ headerLines.join("\n"), metaLines.length > 0 ? metaLines.join("\n") : null, tracklistLines.join("\n"), ].filter((s) => s !== null); return sections.join("\n\n"); } function formatTrackLine(track) { const num = track.track_number; const title = track.recording?.title ?? track.work?.title ?? "(untitled track)"; const artist = track.recording?.artist_name ?? track.work?.primary_artist ?? null; const isrc = track.recording?.isrc ?? track.work?.isrc ?? null; const duration = formatDuration(track.recording?.duration_ms); const trailingMeta = []; if (artist) trailingMeta.push(artist); if (isrc) trailingMeta.push(isrc); if (duration) trailingMeta.push(duration); if (trailingMeta.length === 0) return `${num}. ${title}`; return `${num}. ${title} — ${trailingMeta.join(" · ")}`; } function collectPrimaryArtists(tracks) { const seen = new Set(); const out = []; for (const track of tracks) { const artist = track.recording?.artist_name ?? track.work?.primary_artist ?? null; if (!artist) continue; const key = artist.trim().toLowerCase(); if (key.length === 0 || seen.has(key)) continue; seen.add(key); out.push(artist.trim()); if (out.length >= 6) break; } return out; } function extractYear(releaseDate) { if (!releaseDate) return null; const match = /^(\d{4})/.exec(releaseDate); return match ? match[1] : null; } function formatDuration(durationMs) { if (durationMs == null || !Number.isFinite(durationMs) || durationMs <= 0) { return null; } const totalSeconds = Math.round(durationMs / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, "0")}`; } function errorResult(text) { return { content: [{ type: "text", text }], isError: true, }; } function errorMessage(error) { if (error instanceof Error) return error.message; if (typeof error === "string") return error; try { return JSON.stringify(error); } catch { return String(error); } } //# sourceMappingURL=release-rich.js.map