UNPKG

@withpica/mcp-server

Version:

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

767 lines • 34.7 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { ResourceError } from "@withpica/mcp-utils"; import { readFileSync } from "fs"; import { join } from "path"; import { WITHPICA_PRIMER } from "./llms-primer.js"; import { WELCOME_MARKDOWN } from "./welcome.js"; import { buildAgentGuideAuthenticated, buildAgentGuideLobby, } from "./agent-guide.js"; import { ToolRegistry } from "../tools/index.js"; import { REQUIRED_SCHEMAS } from "./required-schemas.generated.js"; import { SkillsRegistry } from "../skills/index.js"; import { UPLOAD_HTML } from "../apps/upload.js"; import { DOWNLOAD_HTML } from "../apps/download.js"; import { RELEASE_HTML } from "../apps/release.js"; import { PICA_STORAGE_CONNECT_DOMAINS } from "../apps/shared.js"; /** * How many tools an authenticated client sees in `tools/list`. * * Read off ToolRegistry rather than written down. The agent guide used to * carry the number as a literal and it rotted — "~232 tools" against a * registry advertising 329 (2026-08-24). `listTools()` already excludes * deprecated aliases, which is the same definition `MCP_TOOL_COUNT` in * `lib/constants/mcp-surface.ts` uses. * * `lobbyMode` and `discoveryMode` are forced off for the count: under * layered discovery `tools/list` returns only the five meta tools, but the * guide is describing the catalog reachable THROUGH `pica_discover`, not * that shortlist. Registration is a pure in-memory pass (no I/O), and the * result is memoised, so a guide read costs one registration per process. * * Asserted by `src/__tests__/resources/agent-guide.test.ts`. */ let memoisedToolCount = null; function advertisedToolCount(config) { if (memoisedToolCount !== null) return memoisedToolCount; const countingConfig = { picaApiKey: "", picaApiUrl: "", serverName: "pica-mcp", version: "0.0.0", debug: false, credentialsPath: "", ...config, lobbyMode: false, discoveryMode: false, }; // Registration only reads the client's shape, never calls it — an empty // object registers the same tools a live client would. const registry = new ToolRegistry({}, countingConfig); memoisedToolCount = registry.listTools().length; return memoisedToolCount; } /** * The tools a LOBBY caller on this transport can actually call. * * Read off a lobby ToolRegistry rather than written down, for the same reason * the count above is: the guide used to hardcode "three tools" and name all * three, which was right for stdio and wrong for the HTTP lobby, where the * sign-in pair declares `audiences: ["stdio"]` and is not offered. This * registry is built with `lobbyMode: true`, so registration stops after the * auth + signup tools; `listTools()` then applies the same alias/deprecation * filter every other listing does. * * ⚠️ NOT memoised, unlike the count: the answer depends on the config passed * in, and a memoised first caller would decide it for every later one. * Registration is a pure in-memory pass, so this is cheap. * * Asserted by `src/__tests__/resources/agent-guide.test.ts`. */ function lobbyToolNames(config) { const lobbyConfig = { picaApiKey: "", picaApiUrl: "", serverName: "pica-mcp", version: "0.0.0", debug: false, credentialsPath: "", ...config, lobbyMode: true, discoveryMode: false, }; const registry = new ToolRegistry(null, lobbyConfig); return registry.listTools().map((t) => t.name); } export class ResourceRegistry { pica; config; skills; constructor(pica, config) { this.pica = pica; this.config = config; this.skills = new SkillsRegistry(); } /** * List all available resources */ listResources() { const resources = [ { uri: "pica://welcome", name: "Welcome to PICA", description: "Getting started guide — how to sign in and what you can do", mimeType: "text/markdown", }, { uri: "pica://docs/agent-guide", name: "Agent Guide — How to Use PICA", description: "Workflow-by-workflow guide for agents — what tools to call in what order, common gotchas, pointers to schema resources. Lobby mode returns a sign-in-focused variant; authenticated mode returns the full workflow map.", mimeType: "text/markdown", }, ]; if (this.config?.lobbyMode || !this.pica) { return resources; } // ADR-140 Phase 2b — skills exposed as SEP-2640-shaped resources. // index.json enumerates; per-skill SKILL.md URIs return frontmatter+body. // // URIs are HARDCODED literals (not generated from this.skills.listSkills()) // because scripts/lint-resource-coverage.ts regex-scans the first push // block after the lobbyMode guard — dynamic URIs from a loop wouldn't be // detected, breaking the cross-transport parity check (ADR-152). When // adding a new skill, register its URI here AND in app/api/mcp/route.ts // UI_RESOURCE_DEFS in the same commit. resources.push({ uri: "skill://withpica/index.json", name: "PICA Skills Index", description: "Enumeration of downloadable PICA skill methodologies (SEP-2640 bridge). Each entry maps a kebab-case name to its skill:// URI, description, trigger phrases, and audience.", mimeType: "application/json", }, { uri: "skill://withpica/catalog-audit/SKILL.md", name: "Skill — catalog-audit", description: "Audit a music catalog for completeness, quality, and registration readiness using PICA's MCP tools.", mimeType: "text/markdown", }, { uri: "skill://withpica/register-my-works/SKILL.md", name: "Skill — register-my-works", description: "Check registration status across collection societies (PRS, MCPS, PPL, MLC) and prepare works for registration.", mimeType: "text/markdown", }, { uri: "skill://withpica/prepare-for-sync/SKILL.md", name: "Skill — prepare-for-sync", description: "Make a catalog sync-ready — audio uploaded and analysed, mood and genre tagged, works findable by music supervisors.", mimeType: "text/markdown", }, { uri: "skill://withpica/set-up-workspace/SKILL.md", name: "Skill — set-up-workspace", description: "Guide a new or returning user through PICA workspace setup — org profile, identifiers, privacy, storage, billing, team.", mimeType: "text/markdown", }, { uri: "skill://withpica/close-the-loop/SKILL.md", name: "Skill — close-the-loop", description: "Walk through a single work's gaps one at a time, offering the specific tool call to fix each.", mimeType: "text/markdown", }, { uri: "skill://withpica/set-recording-splits/SKILL.md", name: "Skill — set-recording-splits", description: "Guided master ownership split assignment on a recording — propose, dry-run preview, confirm, commit, verify.", mimeType: "text/markdown", }, { uri: "skill://withpica/audit-credits/SKILL.md", name: "Skill — audit-credits", description: "Sweep across the catalog for credit gaps and split issues — works with no credits, splits not totalling 100%, missing identifiers.", mimeType: "text/markdown", }, { uri: "skill://withpica/log-a-session/SKILL.md", name: "Skill — log-a-session", description: "Capture a studio session conversationally — what was made, when, where, who was there, what happened.", mimeType: "text/markdown", }, { uri: "skill://withpica/log-a-show/SKILL.md", name: "Skill — log-a-show", description: "Capture a live show or a tour credit conversationally — where, when, who played, what was in the set.", mimeType: "text/markdown", }, { uri: "skill://withpica/enrich-from-peer-mcps/SKILL.md", name: "Skill — enrich-from-peer-mcps", description: "Fill PICA catalog gaps by composing across the user's other connected MCPs (Gmail, Drive, Notion, calendar, DAW).", mimeType: "text/markdown", }, { uri: "skill://withpica/first-session/SKILL.md", name: "Skill — first-session", description: "Agent's first move per session — recognize identity, load briefing + attention queue, rank top priorities by class, route each to the right specialist skill. Bakes in mid-session reporting discipline.", mimeType: "text/markdown", }, { uri: "skill://withpica/import-my-catalog/SKILL.md", name: "Skill — import-my-catalog", description: "Get music into PICA — routes to the fastest lane for what the user has: a streaming link, a bulk CSV/spreadsheet, or an uploaded royalty statement.", mimeType: "text/markdown", }, { uri: "skill://withpica/ingest-my-statements/SKILL.md", name: "Skill — ingest-my-statements", description: "Conservative-extraction doctrine for royalty and streaming statement ingestion (PDF/image/xlsx) — flags low-confidence rows, never guesses a figure.", mimeType: "text/markdown", }, { uri: "skill://withpica/catalog-modelling/SKILL.md", name: "Skill — catalog-modelling", description: "The data model for a music catalog — work vs recording vs release, and how to model song variants correctly. Front-load before creating or restructuring entities.", mimeType: "text/markdown", }, { uri: "skill://withpica/clean-up-artists/SKILL.md", name: "Skill — clean-up-artists", description: "Find and fix junk/placeholder artist records — the 'unknown' magnet pattern, YouTube Topic channels, duplicate person rows — then repoint safely with bulk reassign.", mimeType: "text/markdown", }, { uri: "skill://withpica/register-from-your-daw/SKILL.md", name: "Skill — register-from-your-daw", description: "Register the song the user is working on right now — without leaving their DAW — by composing PICA with a peer DAW MCP. Session snapshot as provenance, confirm-before-write.", mimeType: "text/markdown", }, { uri: "llms://primer", name: "PICA Domain Primer", description: "Plain-language description of PICA's domain model, entity relationships, and recommended agent workflows. Read this first.", mimeType: "text/markdown", }, { uri: "works://list", name: "All Works", description: "Complete list of musical works in the catalog", mimeType: "application/json", }, { uri: "people://list", name: "All People", description: "Complete list of people (artists, composers, etc.)", mimeType: "application/json", }, { uri: "recordings://list", name: "All Recordings", description: "Complete list of recordings", mimeType: "application/json", }, { uri: "catalog://stats", name: "Catalog Statistics", description: "Statistics and metrics about the catalog", mimeType: "application/json", }, { uri: "catalog://health", name: "Catalog Health (PICA Score)", description: "PICA Score — composite legibility index (0-100) with grade, 4-pillar breakdown, and actionable suggestions", mimeType: "application/json", }, { uri: "workspace://context", name: "Workspace Context", description: "Aggregated workspace orientation — org info, catalog stats, health score, and top priorities in one call", mimeType: "application/json", }, { uri: "events://recent", name: "Recent Events", description: "Recent webhook delivery events — what happened in this workspace recently", mimeType: "application/json", }, { uri: "api://openapi", name: "OpenAPI Specification", description: "OpenAPI 3.1 spec for the top 20 PICA API endpoints — works, people, recordings, agreements, credits, search, health", mimeType: "text/yaml", }, { uri: "ui://pica/upload", name: "Upload Files", description: "Generic file upload card — drop zone for audio, photos, documents, anything", mimeType: "text/html;profile=mcp-app", }, { uri: "ui://pica/download", name: "Download File", description: "File download card — shows file metadata and a download button", mimeType: "text/html;profile=mcp-app", }, { uri: "ui://pica/release", name: "Release Card", description: "Release rich-render card — cover art + markdown summary (ADR-208 Phase 3 Primitive D, Phase 3 stub)", mimeType: "text/html;profile=mcp-app", }, { uri: "pica://schemas/work-required", name: "Required Fields — Register a Work", description: "Required + recommended fields, CHECK enums, and companion-call orchestration for pica_works_create. Read before calling the work-create workflow.", mimeType: "application/json", }, { uri: "pica://schemas/recording-required", name: "Required Fields — Register a Recording", description: "Required + recommended fields, CHECK enums, and companion-call orchestration for pica_recordings_create.", mimeType: "application/json", }, { uri: "pica://schemas/work-credits-required", name: "Required Fields — Attach Work Credits", description: "Per-row required fields and CHECK enums for pica_credits_update (writer/composer/publisher splits, sum to 100).", mimeType: "application/json", }, { uri: "pica://schemas/recording-credits-required", name: "Required Fields — Attach Recording Credits", description: "Per-row required fields and the role enum for pica_recording_credits_update (MainArtist / FeaturedArtist / Producer / Engineer / etc.).", mimeType: "application/json", }, { uri: "pica://schemas/person-required", name: "Required Fields — Register a Person", description: "Required + recommended fields for pica_people_create (writers, performers, publishers; identifiers settable inline).", mimeType: "application/json", }, { uri: "pica://schemas/audio-upload-required", name: "Required Fields — Upload Audio (3-Step Chain)", description: "Required fields per step of the audio-upload chain (pica_audio_presigned_upload → pica_audio_complete_upload → pica_audio_analyze + poll), plus audio_files file_type / classification CHECK enums.", mimeType: "application/json", }, { uri: "pica://schemas/enrichment-resolve-required", name: "Required Fields — Run Enrichment", description: "Required fields for pica_resolve_work / _person / _recording and the proposals review chain (list → apply / reject). Includes enrichment_proposals entity_type / status / proposal_action enums.", mimeType: "application/json", }, { uri: "pica://schemas/agreement-required", name: "Required Fields — Create an Agreement", description: "Required + recommended fields for pica_agreements_create plus link-work and template-render companions. Includes agreements agreement_type / status / other_party_type CHECK enums.", mimeType: "application/json", }, { uri: "pica://schemas/multimedia-required", name: "Required Fields — Add Multimedia", description: "Required fields for pica_multimedia_create and the import / YouTube / link-work companions. Includes multimedia_items content_type / classification CHECK enums.", mimeType: "application/json", }, { uri: "pica://schemas/export-required", name: "Required Fields — Export the Catalog", description: "Five export destinations (CSV, song-registration, industry-ready, CAR, AI-consent) with destination guidance.", mimeType: "application/json", }, { uri: "pica://schemas/claim-required", name: "Required Fields — Claim a Discovery", description: "Required fields for pica_discoveries_query and the typed claim verbs (credit / custody / artist), plus the generic discoveries_review surface. Includes discovered_credits / _custody / _artists status + match_basis + custody_type CHECK enums.", mimeType: "application/json", }, { uri: "pica://schemas/session-required", name: "Required Fields — Log a Studio Session", description: "Required + recommended fields for pica_sessions_create plus the pica_sessions_types lookup companion. Status values are documented but not DB-CHECK-enforced.", mimeType: "application/json", }, { uri: "pica://schemas/split-sheet-required", name: "Required Fields — Generate a Split Sheet (Publishing)", description: "Required fields for pica_split_sheet_generate. Pre-condition: writer credits on the work must total 100%. Companion calls cover send / get / list.", mimeType: "application/json", }, { uri: "pica://schemas/recording-splits-required", name: "Required Fields — Assign Master Ownership Splits", description: "Required + recommended fields for pica_recording_splits_create. Includes recording_splits split_type + role CHECK enums. Distinct from publishing splits on works.", mimeType: "application/json", }, { uri: "pica://schemas/sync-placement-required", name: "Required Fields — Register a Sync Placement", description: "Required + recommended fields for pica_sync_placements_create — atomic placement + draft sync_license agreement + at least one contact + recordings + evidence sources. Includes sync_placements status / verification_status / confidentiality_level CHECK enums.", mimeType: "application/json", }, { uri: "release-notes://withpica/mcp-server/latest", name: "Release Notes — @withpica/mcp-server", description: "Full CHANGELOG.md for the PICA creator MCP server (platform release notes). For just the last few releases, agents can call pica_release_notes.", mimeType: "text/markdown", }); return resources; } /** * Read a resource by URI */ async readResource(uri) { switch (uri) { case "pica://welcome": return { contents: [ { uri: "pica://welcome", mimeType: "text/markdown", text: WELCOME_MARKDOWN, }, ], }; case "pica://docs/agent-guide": { const lobby = this.config?.lobbyMode || !this.pica; const toolCount = advertisedToolCount(this.config); return { contents: [ { uri: "pica://docs/agent-guide", mimeType: "text/markdown", text: lobby ? buildAgentGuideLobby(lobbyToolNames(this.config), toolCount) : buildAgentGuideAuthenticated(toolCount), }, ], }; } default: break; } if (!this.pica) { throw new ResourceError(`Resource not available in lobby mode: ${uri}`); } // ADR-140 Phase 2b — skill:// URIs. if (uri.startsWith("skill://withpica/")) { return this.readSkillResource(uri); } switch (uri) { case "llms://primer": return { contents: [ { uri: "llms://primer", mimeType: "text/markdown", text: WITHPICA_PRIMER, }, ], }; case "works://list": return await this.getWorksList(); case "people://list": return await this.getPeopleList(); case "recordings://list": return await this.getRecordingsList(); case "catalog://stats": return await this.getCatalogStats(); case "catalog://health": return await this.getCatalogHealth(); case "workspace://context": return await this.getWorkspaceContext(); case "events://recent": return await this.getRecentEvents(); case "api://openapi": return this.getOpenAPISpec(); case "ui://pica/upload": return { contents: [ { uri: "ui://pica/upload", mimeType: "text/html;profile=mcp-app", text: UPLOAD_HTML, _meta: { ui: { csp: { connectDomains: PICA_STORAGE_CONNECT_DOMAINS, }, }, }, }, ], }; case "ui://pica/download": return { contents: [ { uri: "ui://pica/download", mimeType: "text/html;profile=mcp-app", text: DOWNLOAD_HTML, _meta: { ui: { csp: { connectDomains: PICA_STORAGE_CONNECT_DOMAINS, }, }, }, }, ], }; case "ui://pica/release": return { contents: [ { uri: "ui://pica/release", mimeType: "text/html;profile=mcp-app", text: RELEASE_HTML, }, ], }; case "pica://schemas/work-required": case "pica://schemas/recording-required": case "pica://schemas/work-credits-required": case "pica://schemas/recording-credits-required": case "pica://schemas/person-required": case "pica://schemas/audio-upload-required": case "pica://schemas/enrichment-resolve-required": case "pica://schemas/agreement-required": case "pica://schemas/multimedia-required": case "pica://schemas/export-required": case "pica://schemas/claim-required": case "pica://schemas/session-required": case "pica://schemas/split-sheet-required": case "pica://schemas/recording-splits-required": case "pica://schemas/sync-placement-required": { const key = uri.replace("pica://schemas/", ""); const body = REQUIRED_SCHEMAS[key]; if (!body) throw new ResourceError(`Schema not found: ${uri}`); return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify(body, null, 2), }, ], }; } case "release-notes://withpica/mcp-server/latest": { const { readReleaseNotes } = await import("./release-notes.js"); const { uri: releaseUri, mimeType, text } = readReleaseNotes(); return { contents: [ { uri: releaseUri, mimeType, text, }, ], }; } default: throw new ResourceError(`Resource not found: ${uri}`); } } /** * Resolve a skill:// URI to its content. Two URI shapes are accepted: * - skill://withpica/index.json → enumeration JSON * - skill://withpica/<name>/SKILL.md → per-skill markdown (frontmatter + body) * * ADR-140 Phase 2b. SEP-2640 bridge pattern — these URIs are the * forward-compatible lane for when the MCP skill primitive lands. */ readSkillResource(uri) { if (uri === "skill://withpica/index.json") { const index = this.skills.listSkills().map((s) => ({ name: s.name, uri: `skill://withpica/${s.name}/SKILL.md`, description: s.description, triggers: s.triggers, audience: s.audience, })); return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify({ skills: index }, null, 2), }, ], }; } const match = uri.match(/^skill:\/\/withpica\/([a-z0-9-]+)\/SKILL\.md$/); if (!match) { throw new ResourceError(`Malformed skill URI: ${uri}`); } const name = match[1]; if (!this.skills.has(name)) { throw new ResourceError(`Skill not found: ${name}`); } const skill = this.skills.getSkill(name); // Reconstruct the SKILL.md shape: frontmatter + body, so the reader sees // exactly what an external user would download from a `npx skills add` repo. const frontmatter = [ "---", `name: ${skill.name}`, `description: ${skill.description}`, "triggers:", ...skill.triggers.map((t) => ` - ${t}`), `audience: ${skill.audience}`, "tools_required:", ...skill.tools_required.map((t) => ` - ${t}`), `output: ${skill.output}`, "---", "", ].join("\n"); return { contents: [ { uri, mimeType: "text/markdown", text: frontmatter + skill.body, }, ], }; } /** * Get OpenAPI specification resource */ getOpenAPISpec() { // Try multiple paths to find the spec file const possiblePaths = [ join(process.cwd(), "docs/api/openapi.yaml"), join(process.cwd(), "../docs/api/openapi.yaml"), ]; for (const specPath of possiblePaths) { try { const yamlContent = readFileSync(specPath, "utf-8"); return { contents: [ { uri: "api://openapi", mimeType: "text/yaml", text: yamlContent, }, ], }; } catch { // Try next path } } throw new ResourceError("OpenAPI spec not found. Expected at docs/api/openapi.yaml"); } /** * Get works list resource */ async getWorksList() { const works = await this.pica.works.list({ limit: 1000 }); return { contents: [ { uri: "works://list", mimeType: "application/json", text: JSON.stringify({ total: works.length, works: works.map((work) => ({ id: work.id, title: work.title, work_type: work.work_type, iswc: work.iswc, duration_seconds: work.duration_seconds, })), }, null, 2), }, ], }; } /** * Get people list resource */ async getPeopleList() { const people = await this.pica.people.list({ limit: 1000 }); return { contents: [ { uri: "people://list", mimeType: "application/json", text: JSON.stringify({ total: people.length, people: people.map((person) => ({ id: person.id, first_name: person.first_name, last_name: person.last_name, email: person.email, isni: person.isni, musicbrainz_id: person.musicbrainz_id, })), }, null, 2), }, ], }; } /** * Get recordings list resource */ async getRecordingsList() { const recordings = await this.pica.recordings.list({ limit: 1000 }); return { contents: [ { uri: "recordings://list", mimeType: "application/json", text: JSON.stringify({ total: recordings.length, recordings: recordings.map((recording) => ({ id: recording.id, title: recording.title, isrc: recording.isrc, duration_seconds: recording.duration_seconds, work_id: recording.work_id, })), }, null, 2), }, ], }; } /** * Get catalog statistics resource */ async getCatalogStats() { const [works, people, recordings] = await Promise.all([ this.pica.works.list({ limit: 1000 }), this.pica.people.list({ limit: 1000 }), this.pica.recordings.list({ limit: 1000 }), ]); const stats = { overview: { total_works: works.length, total_people: people.length, total_recordings: recordings.length, last_updated: new Date().toISOString(), }, works: { total: works.length, with_iswc: works.filter((w) => w.iswc).length, by_type: { composition: works.filter((w) => w.work_type === "composition") .length, arrangement: works.filter((w) => w.work_type === "arrangement") .length, adaptation: works.filter((w) => w.work_type === "adaptation").length, translation: works.filter((w) => w.work_type === "translation") .length, }, }, people: { total: people.length, with_email: people.filter((p) => p.email).length, with_isni: people.filter((p) => p.isni).length, with_musicbrainz: people.filter((p) => p.musicbrainz_id).length, }, recordings: { total: recordings.length, with_isrc: recordings.filter((r) => r.isrc).length, }, }; return { contents: [ { uri: "catalog://stats", mimeType: "application/json", text: JSON.stringify(stats, null, 2), }, ], }; } /** * Get catalog health (PICA Score) resource */ async getCatalogHealth() { const score = await this.pica.picaScore.get(); return { contents: [ { uri: "catalog://health", mimeType: "application/json", text: JSON.stringify(score, null, 2), }, ], }; } /** * Get workspace context resource — aggregated orientation for agents */ async getWorkspaceContext() { const context = await this.pica.workspace.getContext(); return { contents: [ { uri: "workspace://context", mimeType: "application/json", text: JSON.stringify(context, null, 2), }, ], }; } /** * Get recent events resource — webhook deliveries */ async getRecentEvents() { const events = await this.pica.workspace.getRecentEvents(20); return { contents: [ { uri: "events://recent", mimeType: "application/json", text: JSON.stringify(events, null, 2), }, ], }; } } //# sourceMappingURL=index.js.map