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