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