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