UNPKG

@withpica/mcp-server

Version:

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

330 lines • 19 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { formatSuccess, formatStructuredList } from "@withpica/mcp-utils"; /** `tour_dates.slot` CHECK constraint — mirrors the ADR-313 migration. */ const SHOW_SLOTS = ["headline", "support", "guest", "festival", "dj", "other"]; export class ShowsTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_log_show", tier: "write", previewMode: "dry_run", scope: "write:catalog", audiences: ["stdio", "http", "public"], workflows: "infrastructure", vernacular_kind: "creator-entry", annotations: { title: "log a live show", readOnlyHint: false, idempotentHint: false, }, description: "Use when the user asks: 'log last night's gig', 'I played the Lexington last night', " + "'here's the setlist from the Accra show', 'I played drums for X at the Albert Hall', " + "'I programmed the tracks for X's tour'.\n\n" + "Two shapes, picked by which fields are present:\n" + "1. A dated show — venue + date + setlist.\n" + "2. A tour credit (no venue, no date) — act + year + role.\n\n" + "Creates nothing else on its own: an unmatched setlist title does not become a work, " + "an unresolved venue does not become a venue, and an unrecognised contributor does not " + "become a person. Each comes back as a proposal — confirm it with pica_setlist_match once " + "the catalogue can back it up.\n\n" + "Reaches no performing-rights society: every one of them takes a setlist through a web " + "form filled in by a human, never an API call, so nothing here reaches one on the " + "user's behalf. And a song the user did not write earns them nothing from a performance " + "of it — this tool never implies a claim someone doesn't have.\n\n" + "Draft first: when the details came from a document (an email, a daysheet, a calendar " + "invite, a screenshot), call with dry_run: true, show the user the draft, and log it " + "only after they confirm. The draft also lists shows already logged for the same night.", requiredOneOf: [["venue", "act"]], inputSchema: { type: "object", properties: { venue: { type: "string", description: "Venue name. Required, with date, for a dated show.", }, city: { type: "string", description: "Disambiguates the venue when the name alone is ambiguous (e.g. two venues sharing a name in different cities).", }, country: { type: "string", description: "ISO 3166-1 alpha-2, e.g. NG, GB.", }, date: { type: "string", description: "YYYY-MM-DD. Required, with venue, for a dated show.", }, act: { type: "string", description: "The billed act name. Required, with year and role, for a tour credit. " + "Also accepted on a dated show as the headliner name when that differs from the org's own act.", }, performed_by_person_id: { type: "string", description: "Who performed, if not the org's own act.", }, slot: { type: "string", enum: SHOW_SLOTS, description: "The billing position on the night, e.g. 'headline', 'support', 'guest', 'festival', 'dj', 'other'.", }, setlist: { type: "array", items: { type: "string" }, description: "Song titles in performance order. Titles that don't match an existing work " + "come back unmatched, not created — confirm with pica_setlist_match.", }, dry_run: { type: "boolean", description: "Dated show only. Resolve the venue, match the setlist and check for a show " + "already logged for the same night, and write nothing. Use it to show the user " + "a draft before logging.", }, set_number: { type: "number", description: "For a multi-set night (e.g. a DJ playing two sets).", }, attendance: { type: "number", description: "Headcount at the show, if the user gives one.", }, logged_as: { type: "string", enum: ["act", "contributor"], description: "'contributor' when the user played on someone else's show rather than their own. " + "Matching is then descriptive only: their own catalogue isn't expected to contain " + "these songs, so an unmatched title isn't a gap.", }, year: { type: "number", description: "Required, with act and role, for a tour credit.", }, role: { type: "string", description: "What the user did on the tour (e.g. 'programmed the tracks', 'tour manager'). " + "Required, with act and year, for a tour credit.", }, tour_name: { type: "string", description: "Optional label for the tour, e.g. 'Renaissance World Tour'.", }, person_id: { type: "string", description: "Resolves the tour credit to an exact existing person. Omit to leave the credit " + "unverified rather than guess.", }, email: { type: "string", description: "Resolves the tour credit to an exact person by email. A name alone is not enough " + "to resolve a person — only email or person_id does that.", }, contributors: { type: "array", description: "Optional. Never ask for this unless the user raises it themselves. An email or " + "person_id resolves a contributor exactly; a name alone comes back as a proposal, " + "not a created person.", items: { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, person_id: { type: "string" }, role: { type: "string" }, is_crew: { type: "boolean", description: "True for crew (sound, lighting, tour manager) rather than a performer.", }, }, required: ["role"], }, }, }, required: [], }, }, executor: this.logShow.bind(this), }, { definition: { name: "pica_shows_query", tier: "read", scope: "read:catalog", audiences: ["stdio", "http", "public"], workflows: "infrastructure", vernacular_kind: "creator-entry", annotations: { title: "query live shows", readOnlyHint: true, }, description: "Use when the user asks: 'when did I last play the Lexington', 'what was the setlist from " + "that Accra show', 'what have I played in Lagos this year', 'which shows still need filing'.\n\n" + "Lists the org's shows, newest first (capped at 200). Pass id to fold in one show's full " + "detail, including its setlist lines and their match state.", inputSchema: { type: "object", properties: { id: { type: "string", description: "One show's id. Folds in its setlist detail; other filters are ignored.", }, from: { type: "string", description: "YYYY-MM-DD." }, to: { type: "string", description: "YYYY-MM-DD." }, venue: { type: "string", description: "Matches venue name, partial and case-insensitive.", }, city: { type: "string", description: "Filters to shows in this city, exact match.", }, country: { type: "string", description: "ISO 3166-1 alpha-2, e.g. NG, GB.", }, unfiled_only: { type: "boolean", description: "Only shows that haven't been marked filed.", }, }, }, }, executor: this.queryShows.bind(this), }, { definition: { name: "pica_setlist_match", tier: "write", scope: "write:catalog", audiences: ["stdio", "http", "public"], workflows: "infrastructure", vernacular_kind: "creator-entry", previewMode: "dry_run", annotations: { title: "confirm what was played", readOnlyHint: false, idempotentHint: true, }, description: "Use when the user asks: 'yes that setlist line was Coalminer', 'that one was a cover', " + "'X sang lead on that song', 'yes that was the Lexington not the other venue with a similar name'.\n\n" + "Confirms a setlist line's matched work, that it was a cover, or who performed it. A fuzzy " + "title match from pica_log_show is a candidate, never a fact — only a confirmation here " + "makes it load-bearing. Also confirms a proposed venue candidate for a show, given venue_id " + "and show_id together.", requiredOneOf: [["work_id", "is_cover", "performed_by", "venue_id"]], inputSchema: { type: "object", properties: { item_id: { type: "string", description: "The setlist item id, from pica_shows_query or pica_log_show's response. Required by " + "the endpoint even when only confirming a venue (venue_id + show_id) — pass any item " + "id belonging to that show.", }, work_id: { type: "string", description: "Confirms this line matches this work.", }, is_cover: { type: "boolean", description: "Confirms this line was a cover, not the org's own work.", }, performed_by: { type: "string", description: "Who performed this line, if not the billed act.", }, venue_id: { type: "string", description: "Confirm a proposed venue candidate for the show. Requires show_id.", }, show_id: { type: "string", description: "Required together with venue_id.", }, dry_run: { type: "boolean", description: "When true, returns what would be confirmed without persisting it.", }, }, required: ["item_id"], }, }, executor: this.matchSetlistItem.bind(this), }, ]; } async logShow(args) { const result = (await this.pica.shows.log(args)); if (result.tour_id) { const verification = result.verification ?? "unverified"; // "Logged the tour credit" is only true when the credit was WRITTEN. A // person who cannot be resolved from an email or a person_id leaves the // tour recorded and nobody attached to it, and saying otherwise would be // the one thing this feature must never do. if (result.credit_recorded === false) { return formatSuccess(`Logged the tour (${verification}), but NOT the credit: ` + `${result.credit_proposal?.reason ?? "the person could not be resolved"}. ` + `Give an email or a person_id to record who did what.`, result); } return formatSuccess(`Logged the tour credit (${verification}).`, result); } const matched = result.matched ?? 0; const unmatched = result.unmatched ?? 0; const venueNote = result.venue_id ? "the venue matched" : result.venue_candidates?.length ? `${result.venue_candidates.length} venue candidate(s) to confirm with pica_setlist_match` : "no venue candidate found"; const sameNight = result.possible_duplicate_of ?? []; const draftDuplicateNote = sameNight.length ? ` ${sameNight.length} show(s) already logged for this night (${sameNight.join(", ")}) — ask the user whether this is the same show before logging it.` : ""; const loggedDuplicateNote = result.possible_duplicate_of === null ? " pica could not check for an earlier log of this night, so it may be a duplicate." : sameNight.length ? ` This org already had ${sameNight.length} show(s) logged for the same night (${sameNight.join(", ")}) — tell the user it may be a duplicate.` : ""; if (result.dry_run) { return formatSuccess(`Draft only, nothing saved — ${matched} setlist line(s) would match, ${unmatched} would not ` + `(${venueNote}).${draftDuplicateNote} Show the user this draft and log it only when they confirm.`, result); } return formatSuccess(`Logged the show — ${matched} setlist line(s) matched, ${unmatched} need pica_setlist_match ` + `to confirm (${venueNote}).${loggedDuplicateNote}`, result); } async queryShows(args) { const result = (await this.pica.shows.query(args)); const items = Array.isArray(result?.shows) ? result.shows : []; return formatStructuredList(items, "show", { venue: args.venue, from: args.from, to: args.to, }); } async matchSetlistItem(args) { const { item_id, dry_run, ...rest } = args; if (dry_run === true) { return formatSuccess("Dry run — would confirm this, nothing persisted.", { dry_run: true, would_confirm: { item_id, ...rest }, }); } const result = (await this.pica.shows.matchItem(String(item_id), rest)); // The route applies BOTH when both are supplied, so report both. Reading // the response rather than the request is deliberate: the request says what // was asked for, the response says what actually landed. const confirmed = []; if (result?.venue) confirmed.push("the venue"); if (result?.item) confirmed.push("the setlist line"); return formatSuccess(confirmed.length > 0 ? `Confirmed ${confirmed.join(" and ")}.` : "Nothing was confirmed.", result); } } //# sourceMappingURL=shows.js.map