@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
426 lines (389 loc) • 22.6 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
/**
* ADR-214 — workflow narrative for agents.
*
* Two markdown BUILDERS. ResourceRegistry serves buildAgentGuideLobby() when
* config.lobbyMode || !pica, buildAgentGuideAuthenticated() otherwise.
*
* Nothing here is a literal, because a literal rots: this file claimed
* "~232 tools" on 2026-08-24 while ToolRegistry.listTools() returned 329, and
* the lobby half said "three tools are available" and listed all three by name
* while the HTTP lobby exposes exactly ONE (the sign-in pair is
* `audiences: ["stdio"]`). Counts AND names are parameters, supplied by the
* transport that knows what its own caller can reach. Enforced by
* src/__tests__/resources/agent-guide.test.ts.
*
* Tool sequences are verbatim from ADR-214 Decision section "Top
* workflows" — audit-verified 2026-04-30 against the live ToolRegistry.
*
* Edits to this file are linted by scripts/lint-agent-guide.ts: every
* `pica_*` token must resolve to a real tool in ToolRegistry.
*/
/**
* The signed-in agent guide.
*
* @param toolCount MUST be **the number of catalogue tools THIS caller can
* reach, excluding deprecated aliases**. The two transports compute that
* differently and both are correct:
*
* - **stdio / any flat surface** — `ToolRegistry.listTools().length`, which
* already drops aliases. This is the definition `MCP_TOOL_COUNT` in
* `lib/constants/mcp-surface.ts` uses.
* - **layered HTTP (`/api/mcp`, the connector default)** — the
* scope-filtered allowed set, alias names removed
* (`app/api/mcp/route.ts`, `catalogueToolCount`). `listTools()` is the
* wrong source there: under layered discovery it returns only the
* meta-tools, and the guide would announce that handful while the whole
* catalogue is reachable by name and through `pica_execute`.
*
* Do NOT pass a literal, a frozen constant, or a raw
* `allDefinitions().length` — the last one counts deprecated aliases, which
* are hidden from every listing the caller can see, so the guide would
* promise tools the agent cannot find. `route.test.ts` ("agent guide")
* asserts the route passes a computed, alias-free count.
*/
export function buildAgentGuideAuthenticated(toolCount) {
return `# pica — agent guide
pica manages a music catalog: works (compositions), recordings (masters),
people (writers, performers, publishers), credits, agreements, exports.
multi-tenant per organisation; every write is org-scoped automatically.
you are signed in. ${toolCount} tools are available. fourteen core workflows
below cover most of the surface; for anything not listed, call
\`pica_discover({ query: "..." })\` to search by intent, or \`pica_discover()\`
with no arguments for the category map — then \`pica_tool_details\`.
## top workflows
### register a new work
1. \`pica_works_create\` — title required; iswc settable inline.
2. \`pica_credits_update\` — batch; attach writer/composer credits.
writer_split_percentage values must sum to 100 across all writer rows.
see \`pica://schemas/work-required\` and \`pica://schemas/work-credits-required\`.
### add a recording to an existing work
- \`pica_recordings_create\` — title + artist_name + version_type required
(version_type defaults to \`master\`). isrc and duration_ms are inline.
- see \`pica://schemas/recording-required\`.
### add audio to a recording
1. \`pica_audio_presigned_upload\` — returns a signed url + token.
2. uploader (browser, curl, sdk) PUTs the file to that url.
3. \`pica_audio_complete_upload\` — registers the upload in the catalog.
4. (optional) \`pica_audio_analyze\` — async; extracts bpm, key, mood, and
transcribes lyrics via whisper.
5. \`pica_audio_inspect\` (sections: ["analysis", "status"]) — poll for
analysis results when you called step 4.
- see \`pica://schemas/audio-upload-required\`.
### add recording credits
- \`pica_recording_credits_update\` — batch. role enum is DISTINCT from
work credits (MainArtist / FeaturedArtist / Producer / Engineer / etc.).
- person_id is OPTIONAL — credits can carry just credited_name when the
person isn't in the catalog yet.
- see \`pica://schemas/recording-credits-required\`.
### enrich existing metadata
1. \`pica_resolve_work\` / \`pica_resolve_person\` / \`pica_resolve_recording\` —
tier A identifier matches apply directly; tier B fuzzy matches queue
as proposals.
2. \`pica_enrichment_proposals_list\` — view pending proposals.
3. \`pica_enrichment_proposal_apply\` — commit a proposal you've reviewed.
\`pica_enrichment_proposal_reject\` — dismiss with content-hash suppression.
- see \`pica://schemas/enrichment-resolve-required\`.
### create an agreement
1. \`pica_agreements_create\` — title + agreement_type + other_party_name
required. prefer counterparty_org_id (FK) over free-text other_party_name.
2. \`pica_agreements_link_work\` — attach works (optional royalty_split per work).
3. \`pica_agreement_templates_render\` — template-driven alternative.
- see \`pica://schemas/agreement-required\`.
### add multimedia
- \`pica_multimedia_create\` — title + content_type required.
- \`pica_multimedia_link_youtube\` — YouTube videos (auto-enriched).
- \`pica_multimedia_import_url\` — external image / video URL (downloads
into PICA storage).
- \`pica_multimedia_link_work\` — attach to a work (relationship_type required).
- see \`pica://schemas/multimedia-required\`.
### log a studio session
1. (optional) \`pica_sessions_types\` — pre-flight to discover valid
\`session_type_id\` values for this org. Lookup table; org-scoped.
2. \`pica_sessions_create\` — title + start_time + end_time (ISO 8601).
session_type_id, location, work_id, recording_id, participants are
all optional inline fields.
3. \`pica_sessions_inspect\` / \`pica_sessions_query\` — read-back / browse.
- the \`status\` enum (scheduled / in_progress / completed / cancelled) is
documented but NOT enforced by a DB CHECK — values may evolve.
- see \`pica://schemas/session-required\`.
### generate a split sheet (publishing royalties on a work)
1. **pre-condition:** writer credits on the work must already total 100%
via \`pica_credits_update\`. \`pica_split_sheet_generate\` returns 400
otherwise.
2. \`pica_split_sheet_generate\` — work_id only; snapshots current splits.
3. \`pica_split_sheet_send\` — notify each credited writer for signature
via in-app + Telegram + email. Non-PICA-user writers get a
collaboration invite email instead. Per-writer soft-fails.
4. \`pica_split_sheet_get\` / \`pica_split_sheet_list\` — read-back.
- see \`pica://schemas/split-sheet-required\`.
### assign master ownership splits (recording rights)
- DISTINCT from publishing splits above. Recording splits live on
\`recording_splits\`, not \`work_credits\`. Drives master royalty payouts
+ downstream credit gating.
1. \`pica_recording_splits_create\` — recording_id + split_type
(master / mechanical / performance / sync) + percentage required.
role / person_id / territory / start_date / end_date optional.
Use \`dry_run: true\` first — it writes nothing and returns the shares already recorded plus \`likely_refusal\`.
ADR-200 form-confirmation gate fires on connectors that support
\`elicitation/url\`.
2. \`pica_recording_splits_verify\` — records that the split HOLDER agreed. Refuses agents
(\`VERIFICATION_REQUIRES_HUMAN_SESSION\`): the holder confirms it signed in, via "confirm my
share" under ownership splits at /inspect/recordings/<recording_id>#ownership-splits.
3. \`pica_recording_splits_list\` — list all splits on a recording.
4. a share is wrong → \`pica_recording_splits_update\` (recording_id + split_id + the fields
to change: person_id to relink an "unknown" holder, percentage, role, split_type,
territory, dates). The 100% total is checked after the change, this row's old value left
out. Changing a term (who, how much, type, territory, dates — not role or notes) clears the holder's confirmation — the result says
\`verification_cleared: true\`; tell the user the holder must confirm again.
5. a share should stop counting → \`pica_recording_splits_end\` (end_date defaults to today;
the row is kept as history). Don't add a second share to work around a wrong one.
- see \`pica://schemas/recording-splits-required\`.
### assign an industry identifier (iswc / isrc / isni / ipi / etc.)
**there is no standalone identifier-assign tool.** every identifier is
an inline field on the entity's update tool. don't search for a
generic identifier-assign verb — set the field on \`pica_works_update\`
/ \`pica_recordings_update\` / \`pica_people_update\` directly.
- **work identifiers** → \`pica_works_update\`: \`iswc\`, \`isrc\`,
\`tunecode\`, \`society_registrations\`, \`spotify_track_uri\`.
- **recording identifiers** → \`pica_recordings_update\`: \`isrc\`,
\`isrc_source\`, \`musicbrainz_recording_id\`, \`mlc_recording_id\`,
\`mlc_song_code\`, \`spotify_track_uri\`, \`spotify_track_id\`,
\`youtube_video_id\`, \`youtube_url\`, \`apple_music_url\`,
\`deezer_track_id\`, \`ppl_recording_id\`.
- **person identifiers** → \`pica_people_update\`: \`cae_ipi_number\`,
\`ipn_number\`, \`prs_number\`, \`ppl_number\`, \`mcps_member_number\`,
\`pro_name\` + \`pro_member_number\`, plus the satellite-routed
identifiers below.
**satellite-routed person identifiers (set them inline anyway):**
\`isni\`, \`musicbrainz_id\`, \`wikidata_id\`, \`discogs_artist_id\`,
\`deezer_artist_id\`, \`imdb_id\`, \`viaf_id\` (and the biographical
fields such as \`languages_spoken\`, \`date_of_death\`) are stored on the
\`person_enrichment\` satellite table under the hood, NOT on \`people\`.
**don't think about that.** set them as inline fields on
\`pica_people_update\` exactly like the non-satellite identifiers above;
the service fans out automatically. the surface is uniform — satellites
are an implementation detail.
**clearing wrong enrichment:** every field enrichment fills is editable
and clearable on \`pica_people_update\` — send \`null\` (or \`[]\` for a
list) to remove a misattributed value. the edit is recorded as the
user's, and later enrichment runs will not put the old value back.
field-name notes — these names are surface-precise; the route's
allow-list rejects the abbreviated forms:
- \`cae_ipi_number\` (NOT \`cae_ipi\`)
- \`ipn_number\` (NOT \`ipn\`)
- \`pro_member_number\` (the generic non-PRO/PPL/MCPS variant) is paired
with \`pro_name\` to specify which PRO.
**examples:**
- \`pica_works_update { id: "<uuid>", iswc: "T-123.456.789-0" }\`
- \`pica_people_update { id: "<uuid>", cae_ipi_number: "00000000001",
isni: "0000000123456789" }\`
### export works
- one of: \`pica_export_catalog_csv\` (per-work or per-batch — picks format pro / distributor / nro / sync-pdf, requires work_id or work_ids) /
\`pica_export_song_registration\` (catalog-wide registration bundle for PRO/CMO — CSVs plus a CWR field-mapping guide, NOT a CWR file) /
\`pica_export_industry_ready\` (catalog-wide publisher / label / sync package) /
\`pica_export_catalog_asset_report\` (CAR — finance / insurance / diligence) /
\`pica_export_ai_consent\` (AI-consent matrix).
- see \`pica://schemas/export-required\` for which export matches which destination.
### claim a discovery
1. \`pica_discoveries_query\` — list pending; row carries
\`type: 'credit' | 'custody' | 'artist'\`.
2. typed claim: \`pica_claim_credit\` / \`pica_claim_custody\` /
\`pica_claim_artist\`. or \`pica_discoveries_review\` for generic accept/reject.
- see \`pica://schemas/claim-required\`.
## reading the catalog
write-flows above assume you already know what to write. these read-
patterns help you find the right entity / gap / duplicate first.
### find duplicates and merge them
1. \`pica_find_duplicates\` — \`entity_type: "works" | "people" |
"releases" | "recordings" | "audio_files"\`. returns groups + suggested
winners. releases key on UPC, recordings on ISRC, and \`audio_files\`
finds the SAME FILE uploaded twice (exact byte size + filename — audio
rows carry no content hash). each audio item reports \`storage_bucket\`;
a \`withpica-*\` bucket is storage pica pays for.
2. \`pica_merge_duplicates\` — a NARROWER set: \`"works" | "people" |
"releases" | "recordings"\` only. \`entity_type\` accepts both forms;
the canonical plural is preferred, legacy singular
(\`"work" | "person"\`) still works for backwards compatibility. max 25
loser_ids per call. requires admin scope.
3. several groups confirmed? send them in ONE call as \`merges\` (up to
50 \`{entity_type, winner_id, loser_ids}\`). over a connector that is
ONE approval email for the owner, not one per group. after approval,
\`pica_approval_status\` gives each pair's outcome — merged, skipped
(already merged or deleted) or failed with the reason.
- ⚠️ \`audio_files\` can be FOUND but not MERGED. there is no merge
semantics for a file — the outcome is a person deleting the redundant
upload. passing it to \`pica_merge_duplicates\` is refused.
- if you need to merge but don't have admin scope, ping a workspace
owner — there is no agent-side workaround.
### two different people sharing one identity — split, never merge
when two people who are NOT the same human share a global identity (a
wrong ISNI or Wikidata match joined them; custody transfers to it land as
"unknown"), merging is wrong.
1. \`pica_people_inspect\` \`{id, sections: ["global_identity"]}\` — who
shares it, its identifiers, unattributed split rows and open custody
legs naming it, and which of the person's identifiers equal it.
2. ask the user which identifiers and which rows are this person's.
3. \`pica_people_split_identity\` with only what they confirmed. nothing
moves by default; never infer an identifier or a row.
### audit catalog completeness
1. \`pica_dashboard_briefing\` — high-level state: counts of
needs_attention works, recent activity, unresolved discoveries,
per-org \`agent_velocity\` block (workflow_outcomes events trailing 7d
sliced by kind + top tools, ADR-247 PR b). start here, not at
\`*_query\`.
2. drill into specifics from the briefing's pointers — usually a
\`pica_*_query\` filtered by health, then \`pica_*_inspect\` per row.
### find gaps and fill them
1. \`pica_works_query { health_filter: "needs_attention" | "low_completeness" }\`
— under-used. surfaces works missing credits / iswc / metadata.
parallel filters exist on \`pica_recordings_query\` /
\`pica_people_query\` (\`pica_people_query\` is full-text only — no
typed health_filter yet).
2. \`pica_works_inspect { id, sections: ["health", "completeness", "enrichment"] }\`
— request only the sections you need. sections=[] (omit) returns
everything which is noisy.
3. if the gap is identifier-shaped (missing ISWC / ISRC / ISNI /
MusicBrainz id) — \`pica_resolve_work\` / \`_recording\` / \`_person\`
fans the cascade. tier B fuzzy matches queue as proposals — drain
via \`pica_enrichment_proposals_list\` → \`_apply\` / \`_reject\`.
### look up a person by industry identifier
- \`pica_people_query\` accepts a free-text \`query\` field that matches
name, email, OR identifier (IPI / ISNI / etc.). there is NO typed
filter (\`pica_people_query { isni: "..." }\` does not work) — use
the \`query\` string.
- if no match, fall back to \`pica_search_all\` for a cross-entity scan.
- if still no match, the person isn't in the catalog yet —
\`pica_people_create\` (set the identifier inline; see the
identifier-assign section above) → \`pica_resolve_person\` to fan
enrichment across ISNI + MusicBrainz.
## common gotchas
- **duration units differ.** \`works.duration_seconds\` (integer seconds);
\`recordings.duration_ms\` (integer milliseconds);
\`audio_files.duration_seconds\` (seconds again).
- **no standalone identifier-assign tool.** every identifier (ISWC,
ISRC, ISNI, CAE/IPI, MusicBrainz id, etc.) is an inline field on the
entity's update tool — see the "assign an industry identifier"
section above for the surface-precise field names.
- **two write surfaces for collaborators, and three verbs.**
\`pica_credits_update\` is canonical for work splits and REPLACES the
whole set; \`pica_recording_credits_update\` is the recording-side variant
with a different role enum (MainArtist / FeaturedArtist / Producer / etc.).
don't cross them. to touch ONE credit use the single-credit verbs instead:
\`pica_credit_add\` (one new row), \`pica_credit_update\` (edit a row in
place — a writer's share, an instrument, a name, or who it is for via
\`person_id\` — keeping its id; a change of person sends a confirmed,
disputed or declined credit back to pending; find the credit_id with \`pica_credits_list\`) and
\`pica_credit_remove\`. changing one split does not require re-sending
the others. a split edit reports the work's \`allocation.status\`
(closed / open / incomplete) — if it reads open, ask the user how the
remainder is shared; never redistribute it yourself.
- **people identifiers — treat as one inline surface.** \`pica_people_update\`
accepts every identifier as an inline field; some route to the
\`person_enrichment\` satellite under the hood (see the identifier
section above) — the surface stays uniform.
- **audio analysis is async + explicit.** \`pica_audio_complete_upload\`
does NOT auto-trigger analysis. call \`pica_audio_analyze\` and poll.
- **lyrics from analysis auto-copy only into an EMPTY \`works.lyrics\`.**
whisper transcripts land on the transcriptions row; when the linked
work's \`lyrics\` is null the text + \`lyrics_language\` are copied
across. existing lyrics are never overwritten.
- **org scoping + duplicate prevention.** writes are org-scoped; iswc
and isrc duplicates are blocked at write.
## schema resources (pre-flight before each workflow)
- \`pica://schemas/work-required\` — backs \`pica_works_create\`.
- \`pica://schemas/recording-required\` — backs \`pica_recordings_create\`.
- \`pica://schemas/work-credits-required\` — backs \`pica_credits_update\`.
- \`pica://schemas/recording-credits-required\` — backs \`pica_recording_credits_update\`.
- \`pica://schemas/person-required\` — backs \`pica_people_create\`.
- \`pica://schemas/audio-upload-required\` — 3-step audio chain.
- \`pica://schemas/enrichment-resolve-required\` — resolve → proposals chain.
- \`pica://schemas/agreement-required\` — backs \`pica_agreements_create\`.
- \`pica://schemas/multimedia-required\` — backs \`pica_multimedia_create\`.
- \`pica://schemas/export-required\` — five export destinations.
- \`pica://schemas/claim-required\` — three discovery claim verbs.
- \`pica://schemas/session-required\` — backs \`pica_sessions_create\`.
- \`pica://schemas/split-sheet-required\` — backs \`pica_split_sheet_generate\` (publishing splits on works).
- \`pica://schemas/recording-splits-required\` — backs \`pica_recording_splits_create\` (master ownership splits on recordings).
each schema returns a json contract: required fields, recommended fields,
CHECK enum values, companion-call orchestration, and an example payload.
`;
}
/**
* One line of explanation per lobby tool. Keyed by tool name so the guide can
* render whatever subset a transport actually exposes; a name with no entry
* still renders (bare, no blurb) rather than vanishing from the list, because
* an unlisted callable tool is the failure mode this whole change is about.
*
* Every key must resolve to a real tool — `scripts/lint-agent-guide.ts` reads
* the `pica_*` tokens out of this file and checks them against ToolRegistry.
*/
const LOBBY_TOOL_BLURBS = {
pica_signup_start: "create a new organisation and get a magic link for first-time setup.",
pica_sign_in: "request a magic link for an existing user.",
pica_sign_out: "drop session.",
};
/** "one tool is" / "two tools are" / "17 tools are" — no bare digit for 1-10. */
const SPELLED = [
"zero",
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
];
function countPhrase(n) {
const word = n < SPELLED.length ? SPELLED[n] : String(n);
return n === 1 ? `${word} tool is` : `${word} tools are`;
}
/**
* The lobby guide, served when there is no authenticated client.
*
* @param lobbyToolNames The tools THIS caller can actually call right now, in
* the order they should be read. Names, not a count: the two transports
* expose different sets. stdio lists all three registered lobby tools;
* the HTTP lobby (`app/api/mcp/route.ts`, `lobbyToolNames`) filters to
* `audiences ∋ "lobby"` and only `pica_signup_start` declares it, so the
* sign-in pair is not callable there. The guide previously hardcoded
* "three tools" plus all three names, so two thirds of the HTTP lobby's
* instructions named tools that would be refused.
* @param unlockedToolCount The count of catalogue tools an AUTHENTICATED
* client would reach — the sentence it lands in is "what unlocks after
* sign-in", so it is deliberately NOT `lobbyToolNames.length`.
*/
export function buildAgentGuideLobby(lobbyToolNames, unlockedToolCount) {
const toolLines = lobbyToolNames
.map((name) => {
const blurb = LOBBY_TOOL_BLURBS[name];
return blurb ? `- \`${name}\` — ${blurb}` : `- \`${name}\``;
})
.join("\n");
// Name the tools the caller has, in the order given. With one tool there is
// no "or".
const callStep = lobbyToolNames.length
? lobbyToolNames.map((n) => `\`${n}\``).join(" or ")
: "the lobby tool listed above";
return `# pica — sign in to begin
you are in lobby mode. ${countPhrase(lobbyToolNames.length)} available; the rest
unlock after sign-in.
## lobby tools
${toolLines}
## to sign in
1. ask the user for an email address.
2. call ${callStep}.
3. user clicks the magic link. on the next request, the catalog tool
surface unlocks automatically — no config to refresh.
## what unlocks after sign-in
${unlockedToolCount} tools across catalog (works, recordings, people, credits, sessions,
splits), enrichment, exports, agreements, multimedia, analytics, and
audio. fourteen required-fields schema resources at
\`pica://schemas/<workflow>-required\`.
the full agent-guide replaces this lobby content.
`;
}
//# sourceMappingURL=agent-guide.js.map