UNPKG

@withpica/mcp-server

Version:

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

206 lines • 10.9 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { formatSuccess, formatStructuredList, pageEnvelope, } from "@withpica/mcp-utils"; // Server default when the caller omits limit — mirrors GET /admin/notes' // own default (route.ts: `parseInt(limit || "20", 10) || 20`), so // pageEnvelope's `limit` reflects what the server actually applied even // when the agent didn't pass one. const DEFAULT_NOTES_QUERY_LIMIT = 20; export class NotesTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ // Tier 2 (MCP tool-surface consolidation, ADR-149 query/inspect // pattern): pica_notes_query/_inspect are the canonical read pair, // mirroring pica_releases_query/_inspect. (pica_notes_get was removed // in the 2.93.0 sweep and pica_notes_list in 2.99.0; getNote and // listNotes stay — they back this pair.) { definition: { name: "pica_notes_query", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "List notes — filter by search query, linked work, or linked person. Notes can be attached to works, people, or stand alone. " + "For one note in full by id, use pica_notes_inspect.", 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: { q: { type: "string", description: "Search notes by content or title", }, work_id: { type: "string", description: "Filter notes linked to a specific work", }, person_id: { type: "string", description: "Filter notes linked to a specific person", }, limit: { type: "number", description: "Max results (default 20)", }, offset: { type: "number", description: "Pagination offset — pass next_offset from the previous response to fetch the next page (default 0).", }, }, }, }, executor: this.listNotes.bind(this), }, { definition: { name: "pica_notes_inspect", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Get a specific note by ID. " + "To search or filter notes instead of fetching one by id, use pica_notes_query.", 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.", // id/note_id are interchangeable entry points — see // pica_recordings_inspect. required:[] + requiredOneOf lets // pica_execute still hard-reject empty/stripped args while // accepting either name. requiredOneOf: [["id", "note_id"]], inputSchema: { type: "object", properties: { id: { type: "string", description: "Note ID" }, note_id: { type: "string", description: "Note ID (alias for id — write tools use note_id).", }, }, required: [], }, }, executor: this.getNote.bind(this), }, { definition: { name: "pica_notes_create", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Add a note to the catalog, optionally attached to specific works and people. " + "To attach it, pass `work_ids` / `people_ids` — those are authoritative (each id must be a live row in " + "this catalog, or the note is refused with the bad ids named). For a kind you do not pass, PICA links " + "only on strong evidence: a work whose exact title appears in the note (quoted, or written in title case " + "as a whole phrase), a person whose exact full name appears. A title shared by several works is never " + "guessed — the receipt lists it under ambiguous_work_titles so you can pass the right work_id. " + "Pass an empty array to attach none of that kind. " + "Content and the links are the only things you set — scope, type, key, confidence, source, expiry " + "and the rest of metadata are filled in for you. " + "To find or re-read existing notes rather than add one, use pica_notes_query.", 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: { content: { type: "string", description: "The note text. Write work titles and people's full names as they appear in the catalog, so they can be linked when no ids are passed.", }, work_ids: { type: "array", items: { type: "string" }, maxItems: 50, description: "Works this note is about (ids from this catalog). Replaces title auto-linking for works; [] attaches none.", }, people_ids: { type: "array", items: { type: "string" }, maxItems: 50, description: "People this note is about (ids from this catalog). Replaces name auto-linking for people; [] attaches none.", }, }, required: ["content"], }, }, executor: this.createNote.bind(this), }, { definition: { name: "pica_notes_delete", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "destructive", description: "Delete a catalog note by id (destructive — no undo). " + "Confirm which note you are deleting with pica_notes_inspect first.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Destructive operation; invoked by exact name after explicit user confirmation in a specialist flow.", inputSchema: { type: "object", properties: { id: { type: "string", description: "Note ID to delete" }, }, required: ["id"], }, }, executor: this.deleteNote.bind(this), }, ]; } async listNotes(args) { const result = await this.pica.notes.list({ q: args.q, work: args.work_id, person: args.person_id, limit: args.limit, offset: args.offset, }); // GET /admin/notes returns {success, data: {data, count, pagination}} and // the SDK's request() narrows to `.data` — so this tool receives the // OBJECT {data, count, pagination}, never a bare array. The previous // Array.isArray(result) ? result : [] silently rendered an empty list on // every real call (2026-07-11 envelope-unwrap class sweep). Unwrap the // inner data array; keep the bare-array branch defensively. const notes = Array.isArray(result) ? result : (result?.data ?? []); // The route runs a real COUNT query alongside the page fetch (route.ts), // so — unlike audio's limit+1 probe — an honest `total` is available; // pageEnvelope uses it directly instead of the no-total fallback. const pagination = Array.isArray(result) ? undefined : result?.pagination; const page = pageEnvelope({ limit: args.limit ?? DEFAULT_NOTES_QUERY_LIMIT, offset: args.offset ?? 0, returned: notes.length, total: pagination?.total, }); return formatStructuredList(notes, "note", { page }); } async getNote(args) { const _id = args.id ?? args.note_id; const note = await this.pica.notes.get(_id); return formatSuccess("Note retrieved", note); } async createNote(args) { const links = {}; if (args.work_ids !== undefined) links.work_ids = args.work_ids; if (args.people_ids !== undefined) links.people_ids = args.people_ids; const note = await this.pica.notes.create(args.content, Object.keys(links).length > 0 ? links : undefined); return formatSuccess("Note added", note); } async deleteNote(args) { await this.pica.notes.delete(args.id); return formatSuccess(`Note ${args.id} deleted`); } } //# sourceMappingURL=notes.js.map