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