UNPKG

@withpica/mcp-server

Version:

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

180 lines • 10.5 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { formatAsText, formatSuccess } from "@withpica/mcp-utils"; export class SearchTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_search_all", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'find this song', 'find this person', 'find this recording', 'look up X', 'search for X', 'look up artist X'. " + "Canonical cross-entity search — the single entry point for any 'find X' query across works, people, recordings, physical assets, audio files, studio sessions, agreements, multimedia, and releases. Pass kind={'work'|'person'|'recording'|...} to scope when the entity type is known; omit for fuzzy cross-entity match. " + "Supports free text, ISRC, ISWC, and IPI lookups. Returns ranked results with the top match expanded to full chain detail. " + "If the response carries ambiguity.ambiguous=true, the top matches were too close to call — ask the user which candidate they mean before acting on a specific entity, then re-query by id. Do not act on a guess. " + "→ then: pica_works_inspect (work details), pica_people_inspect (person details), pica_recordings_inspect (recording details), pica_multimedia_search (structured media filters: type, classification, published), pica_search_for_sync (audio-feature/sync search: mood, bpm, key, energy).", workflows: "infrastructure", vernacular_kind: "creator-entry", // query/q are interchangeable. `q` is the single most common search // param an LLM reaches for — accepting it as an alias stops the // pica_execute dispatcher hard-rejecting `{q:"..."}` with // WRAPPED_ARGS_MISSING (in-app assistant roadblock 2026-07-05, where // rejected `q` searches cascaded into hallucinated-UUID NOT_FOUNDs). // Neither can sit in JSON-Schema `required` — requiredOneOf enforces // "at least one present" without tripping the empty/stripped guard. requiredOneOf: [["query", "q"]], inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query — free text, ISRC, ISWC, or IPI number", }, q: { type: "string", description: "Alias for query — accepted because agents commonly pass the search term as `q`.", }, limit: { type: "number", minimum: 1, maximum: 50, description: "Maximum results (default: 10, max: 50)", }, expand_first: { type: "boolean", description: "Expand the top result to full chain detail (default: true)", }, }, }, }, executor: this.searchAll.bind(this), }, { definition: { name: "pica_catalog_stats", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'how many songs/recordings/releases/people do I have?', 'how many have I produced?', 'who's my top collaborator?', 'genre breakdown', 'releases per year'. " + "Raw counts and one-dimension breakdowns for the catalog. " + "For what needs attention use pica_catalog_health or pica_dashboard_briefing instead.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { entity: { type: "string", enum: ["works", "recordings", "releases", "people"], description: "Which entity to count. Omit for a flat overview of every entity total.", }, group_by: { type: "string", description: "Break the count down by one dimension. works: genre|work_type|work_status|year|role|collaborator. recordings: year|role. releases: year|release_format|label. people: role. Requires entity. For a year breakdown pass group_by=year. Omit to get just the total.", }, }, }, }, executor: this.getCatalogStats.bind(this), }, { definition: { name: "pica_graph_traverse", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Traverse the global identity graph from any identifier (IPI, ISNI, ISRC, ISWC, MusicBrainz UUID). " + "Returns the canonical global entity + all connected works, recordings, creators, and publishers across the graph. " + "Also shows which entities exist in your catalog. " + "Use this to answer: 'show me everything connected to this IPI/ISRC/ISWC' or 'what else has this person written?'. " + 'For everything connected to a person INSIDE your own catalog, use pica_people_inspect sections:["connections"] instead.\n\n' + "→ then: pica_works_inspect (deep-dive a work), pica_resolve_work (enrich a discovered work)", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { identifier: { type: "string", description: "The identifier to traverse from. Supports: IPI (9-11 digits), ISNI (16 digits), " + "ISRC (e.g. GBUM71804829), ISWC (e.g. T-123.456.789-0), MusicBrainz UUID.", }, }, required: ["identifier"], }, }, executor: this.graphTraverse.bind(this), }, ]; } /** * Unified search via chain service — single request, all entity types, * fuzzy matching with pg_trgm, ranked by similarity. */ async searchAll(args) { // Accept `q` as an alias for `query` — see the requiredOneOf note on the // tool definition. requiredOneOf guarantees one is present at dispatch, so // the value is a string here. The chain endpoint's own param is also `q`. const query = (args.query ?? args.q); const result = await this.pica.chain.search({ q: query, limit: args.limit, expand_first: args.expand_first, }); // WS3: when the matcher couldn't tell the top candidates apart, lead the // TEXT with the ambiguity banner so no client (text-only ones included) // can miss the ask-the-user moment. structuredContent carries the block // via the envelope itself. const amb = result.ambiguity; if (amb?.ambiguous) { const formatted = formatAsText(result); const banner = `⚠ AMBIGUOUS MATCH — ${amb.guidance}\ncandidates:\n` + amb.candidates .map((c) => ` - ${c.name} (${c.kind} ${c.id}, confidence ${c.confidence})`) .join("\n") + "\n\n"; return { ...formatted, content: [{ type: "text", text: banner + formatted.content[0].text }], }; } return formatAsText(result); } /** * Get catalog statistics (uses dedicated SQL count endpoint, no pagination limits) */ async getCatalogStats(args) { const stats = await this.pica.catalogStats({ entity: args.entity, group_by: args.group_by, }); return formatAsText(stats); } /** * Traverse the global identity graph from any identifier */ async graphTraverse(args) { const result = await this.pica.chain.traverse(args.identifier); const data = result.data || result; if (!data.global_entity) { return formatAsText({ message: `No entity found in the global identity graph for identifier "${args.identifier}" (detected type: ${data.identifier_type}).`, hint: "Check the identifier format. IPI = 9-11 digits, ISNI = 16 digits, ISRC = 12 chars (e.g. GBUM71804829), ISWC = T + 10 digits.", }); } const summary = [ `Found ${data.global_entity.type}: ${data.global_entity.name}`, `Identifier type: ${data.identifier_type}`, `Connected: ${data.connected.works.length} works, ${data.connected.recordings.length} recordings, ${data.connected.creators.length} creators, ${data.connected.publishers.length} publishers`, `In your catalog: ${data.org_matches.works} works, ${data.org_matches.people} people, ${data.org_matches.recordings} recordings`, ].join("\n"); return formatSuccess(summary, data); } } //# sourceMappingURL=search.js.map