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