UNPKG

@withpica/mcp-server

Version:

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

849 lines • 39.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, SkillEntryNotFoundError, getSkillEntry, listSkillEntries, renderSkillMd, skillNameFromUri, } 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); } /** * The page each list resource returns: the most its route will hand back in * one request (`/admin/works` and `/admin/recordings` clamp at 100, * `/admin/people` at 500). Asking for more is silently clamped, not refused. */ const LIST_RESOURCE_PAGE = { works: 100, people: 500, recordings: 100, }; 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 resources (ADR-328: also via // skills/list + skills/get). index.json is the fallback enumeration for // hosts without the extension; per-skill SKILL.md URIs return // renderSkillMd output. // // 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 (fallback for hosts without the MCP skills extension's skills/list). 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/tag-my-songs/SKILL.md", name: "Skill — tag-my-songs", description: "Help the owner tag their listed songs with what they are about and how they sound, for directory search. The owner approves every tag.", mimeType: "text/markdown", }, { uri: "skill://withpica/look-after-clients/SKILL.md", name: "Skill — look-after-clients", description: "For organisations that look after other people's catalogues: see every client, find what needs doing first, and fix it inside the right client's catalogue within what they allowed.", mimeType: "text/markdown", }, { uri: "skill://withpica/who-can-see-my-catalogue/SKILL.md", name: "Skill — who-can-see-my-catalogue", description: "For a catalogue's owner: who looks after your catalogue, what each can see and do, what they have done, and how to answer, change or end it.", 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: "Works", description: "First page of the catalog's musical works, with the true total. Use pica_works_query to page through the rest.", mimeType: "application/json", }, { uri: "people://list", name: "People", description: "First page of people (artists, composers, etc.), with the true total. Use pica_people_query to page through the rest.", mimeType: "application/json", }, { uri: "recordings://list", name: "Recordings", description: "First page of recordings, with the true total. Use pica_recordings_query to page through the rest.", 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: "pica://schemas/relationship-required", name: "Required Fields — Who Looks After a Catalogue", description: "Required fields for inviting, answering, revoking and stepping down from a relationship (an organisation looking after a catalogue): the types, the scope fields, and the expected_version every step sends.", mimeType: "application/json", }, { uri: "pica://schemas/handover-required", name: "Required Fields — Handing Over a Catalogue", description: "Required fields for preparing a catalogue for a client and sending it to them to accept: what the organisation keeps, and where the client answers.", 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": case "pica://schemas/relationship-required": case "pica://schemas/handover-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; ADR-328. SKILL.md text is renderSkillMd's output — the * bytes the skills/list digest covers. index.json is the pre-Final * enumeration, kept for hosts that do not speak the skills extension. */ 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 name = skillNameFromUri(uri); if (!name) { throw new ResourceError(`Malformed skill URI: ${uri}`); } const origin = this.skills.originOf(name); if (!origin) { throw new ResourceError(`Skill not found: ${name}`); } // ADR-328 — the served text comes from renderSkillMd, the renderer the // skills/list digest is computed over; sep2640.test.ts hashes and parses // the served text against the entry. return { contents: [ { uri, mimeType: "text/markdown", text: renderSkillMd(this.skills.getSkill(name), origin), }, ], }; } /** * ADR-328 — SEP-2640 `skills/list`. Empty without a PICA client or in lobby * mode; readResource refuses skill:// reads without a client (its "not * available in lobby mode" throw checks !this.pica). */ listSkillEntries() { if (this.config?.lobbyMode || !this.pica) return []; return listSkillEntries(this.skills); } /** * ADR-328 — SEP-2640 `skills/get`. Throws SkillEntryNotFoundError for an * unknown URI, and in lobby mode, where no skill is served. */ getSkillEntry(uri) { if (this.config?.lobbyMode || !this.pica) { throw new SkillEntryNotFoundError(uri); } return getSkillEntry(this.skills, uri); } /** * 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. * * ⚠️ A resource takes no arguments, so it cannot page: it returns the FIRST * page and says so. `total` is the real catalogue count from * `/admin/catalog/stats` (SQL counts), never the length of the page. Until * 2026-10-06 this asked for `limit: 1000`, the route clamped that to 100, * and `total` reported the page length — an org with 2,239 works was told * it had 100. */ async getWorksList() { const [works, stats] = await Promise.all([ this.pica.works.list({ limit: LIST_RESOURCE_PAGE.works }), this.pica.catalogStats(), ]); const total = stats.works.total; return { contents: [ { uri: "works://list", mimeType: "application/json", text: JSON.stringify({ total, returned: works.length, has_more: total > works.length, ...(total > works.length && { note: `first ${works.length} of ${total} works. call pica_works_query with limit/offset to read the rest.`, }), 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() { // First page + the real count; see getWorksList. const [people, stats] = await Promise.all([ this.pica.people.list({ limit: LIST_RESOURCE_PAGE.people }), this.pica.catalogStats(), ]); const total = stats.people.total; return { contents: [ { uri: "people://list", mimeType: "application/json", text: JSON.stringify({ total, returned: people.length, has_more: total > people.length, ...(total > people.length && { note: `first ${people.length} of ${total} people. call pica_people_query with limit/offset to read the rest.`, }), 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() { // First page + the real count; see getWorksList. const [recordings, stats] = await Promise.all([ this.pica.recordings.list({ limit: LIST_RESOURCE_PAGE.recordings }), this.pica.catalogStats(), ]); const total = stats.recordings.total; return { contents: [ { uri: "recordings://list", mimeType: "application/json", text: JSON.stringify({ total, returned: recordings.length, has_more: total > recordings.length, ...(total > recordings.length && { note: `first ${recordings.length} of ${total} recordings. call pica_recordings_query with limit/offset to read the rest.`, }), 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() { // ⚠️ Every figure comes from SQL counts (`/admin/catalog/stats`, the same // source as pica_catalog_stats), never from list pages. This used to // count the rows of three `list({ limit: 1000 })` calls, which the routes // clamp to 100/500/100 — so every total was a page size — and its // `by_type` keys (composition, arrangement, …) were never values of // works.work_type, so they read 0 for every catalogue. const [counts, byType] = await Promise.all([ this.pica.catalogStats(), this.pica.catalogStats({ entity: "works", group_by: "work_type" }), ]); const buckets = byType .buckets ?? []; const stats = { overview: { total_works: counts.works.total, total_people: counts.people.total, total_recordings: counts.recordings.total, last_updated: new Date().toISOString(), }, works: { total: counts.works.total, with_iswc: counts.works.with_iswc, by_type: Object.fromEntries(buckets.map((b) => [b.key ?? "(none)", b.count])), }, people: { total: counts.people.total, with_email: counts.people.with_email, with_isni: counts.people.with_isni, with_musicbrainz: counts.people.with_musicbrainz, }, recordings: { total: counts.recordings.total, with_isrc: counts.recordings.with_isrc, }, }; 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