@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
701 lines • 37.9 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { formatSuccess, formatStructuredList } from "@withpica/mcp-utils";
export class ProjectsTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
return [
// Tier 2 (MCP tool-surface consolidation, ADR-149 query/inspect
// pattern): pica_projects_query/_inspect are the canonical read pair,
// mirroring pica_releases_query/_inspect. (pica_projects_get was
// removed in the 2.93.0 sweep and pica_projects_list in 2.99.0;
// getProject and listProjects stay — they back this pair.)
{
definition: {
name: "pica_projects_query",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "List projects — albums, EPs, compilations, or any collection of works. " +
"For one project's details and its linked works, use pica_projects_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: {
limit: {
type: "number",
minimum: 1,
maximum: 200,
description: "Max results to return (default 50)",
},
},
},
},
executor: this.listProjects.bind(this),
},
{
definition: {
name: "pica_projects_inspect",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Get project details including linked works. " +
"To list or scan projects rather than open one by id, use pica_projects_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/project_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", "project_id"]],
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Project ID" },
project_id: {
type: "string",
description: "Project ID (alias for id — write tools use project_id).",
},
},
required: [],
},
},
executor: this.getProject.bind(this),
},
{
definition: {
name: "pica_projects_create",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Create a new project to organise collaborative work — writing camps, recording sessions, production/mixing/mastering milestones, retreats. " +
"To attach works after creation call pica_projects_attach_works — work_ids is not accepted here.",
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: {
name: {
type: "string",
description: "Project name (required).",
},
project_type: {
type: "string",
description: "Type of project. Conventional values: writing_camp, recording_session, production, mixing, mastering, retreat, other. Defaults to writing_camp.",
},
description: {
type: "string",
description: "Optional project description.",
},
start_date: {
type: "string",
description: "Project start date (ISO YYYY-MM-DD). Used for scheduling and calendar display.",
},
end_date: {
type: "string",
description: "Project end date (ISO YYYY-MM-DD). Must be on or after start_date if both set.",
},
location_name: {
type: "string",
description: "Physical location name (e.g. 'Abbey Road Studio 2').",
},
location_address: {
type: "string",
description: "Street address of the location.",
},
location_url: {
type: "string",
description: "URL to a map or location page (e.g. Google Maps link).",
},
virtual_url: {
type: "string",
description: "Video-call / virtual-meeting URL for remote participation.",
},
is_open: {
type: "boolean",
description: "Whether the project is open for new participants to join via the invite code. Default: true.",
},
max_participants: {
type: "number",
description: "Maximum number of participants. Null / omitted = no cap.",
},
status: {
type: "string",
enum: [
"draft",
"scheduled",
"active",
"completed",
"cancelled",
],
description: "Project status. Default: draft.",
},
generate_invite_code: {
type: "boolean",
description: "If true, the service generates a unique invite code on create so participants can join. Default: false.",
},
metadata: {
type: "object",
additionalProperties: true,
description: "Optional freeform jsonb metadata (e.g. { notes, tags }).",
},
},
required: ["name"],
},
},
executor: this.createProject.bind(this),
},
{
definition: {
name: "pica_projects_update",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Update project details. Any field from the Create schema can be updated. " +
"To attach or detach works from the project call pica_projects_attach_works — work_ids is not accepted here.",
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: {
id: { type: "string", description: "Project ID (required)." },
name: { type: "string", description: "Updated project name." },
project_type: {
type: "string",
description: "Updated project type. Conventional values: writing_camp, recording_session, production, mixing, mastering, retreat, other.",
},
description: {
type: "string",
description: "Updated description.",
},
start_date: {
type: "string",
description: "Updated start date (ISO YYYY-MM-DD).",
},
end_date: {
type: "string",
description: "Updated end date (ISO YYYY-MM-DD).",
},
location_name: {
type: "string",
description: "Updated location name.",
},
location_address: {
type: "string",
description: "Updated location address.",
},
location_url: {
type: "string",
description: "Updated location URL.",
},
virtual_url: {
type: "string",
description: "Updated virtual-meeting URL.",
},
is_open: {
type: "boolean",
description: "Whether the project is open for new participants.",
},
max_participants: {
type: "number",
description: "Updated participant cap.",
},
status: {
type: "string",
enum: [
"draft",
"scheduled",
"active",
"completed",
"cancelled",
],
description: "Updated project status.",
},
metadata: {
type: "object",
additionalProperties: true,
description: "Updated freeform jsonb metadata.",
},
},
required: ["id"],
},
},
executor: this.updateProject.bind(this),
},
{
definition: {
name: "pica_projects_delete",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "destructive",
description: "Delete a project. Works linked to it are not deleted. " +
"To remove works from a project without deleting the project, use " +
"pica_projects_attach_works with detach: true.",
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: "Project ID to delete" },
},
required: ["id"],
},
},
executor: this.deleteProject.bind(this),
},
{
definition: {
name: "pica_projects_attach_works",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Attach one or more works to a project. " +
"`pica_projects_create` / `_update` do not accept " +
"`work_ids` — use this tool instead. Each item is attached with " +
"an individual POST so partial failure is surfaced per-work in " +
"the result — already-linked works are reported as `skipped` " +
"(not a hard error), and other failures surface their " +
"admin-route error message in `failed[]`.\n\n" +
"Set `detach: true` to remove works from the project instead — " +
"only `work_id` is used per item (`project_day` / `notes` are " +
"ignored in this mode). This only removes the link between them; " +
"the work itself is not affected.",
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: {
project_id: {
type: "string",
description: "Project UUID.",
},
works: {
type: "array",
description: "Works to attach (or detach, if detach: true). Each: { work_id (required), project_day (optional integer >= 1, attach only), notes (optional string, attach only) }.",
items: {
type: "object",
properties: {
work_id: {
type: "string",
description: "Work UUID to attach or detach.",
},
project_day: {
type: "number",
description: "Optional day number within the project (1-indexed). Attach mode only.",
},
notes: {
type: "string",
description: "Optional notes about this attachment. Attach mode only.",
},
},
required: ["work_id"],
},
minItems: 1,
},
detach: {
type: "boolean",
description: "Set true to remove the listed works from the project instead of attaching them. Default false.",
},
},
required: ["project_id", "works"],
},
},
executor: this.attachOrDetachWorks.bind(this),
},
{
definition: {
name: "pica_projects_participants",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Manage a project's participants (the people invited to a writing camp, " +
"recording session, or other project — separate from the works attached via " +
"pica_projects_attach_works).\n\n" +
"action: 'list' (default) — list every participant on project_id, optionally " +
"filtered by status/role (filtered client-side after fetching).\n" +
"action: 'add' — add one participant via person_id (+ optional role, notes), " +
"or several at once via participants: [{person_id, role?, notes?}].\n" +
"action: 'update' — update role/status/notes for participant_id.\n" +
"action: 'remove' — remove participant_id from the project.\n\n" +
"→ then: pica_people_query (find a person_id to add)",
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: {
project_id: { type: "string", description: "Project ID." },
action: {
type: "string",
enum: ["list", "add", "update", "remove"],
description: "Which operation to perform. Default 'list'.",
},
status: {
type: "string",
enum: [
"invited",
"confirmed",
"declined",
"tentative",
"attended",
"no_show",
"cancelled",
],
description: "action 'list': filter to this status. action 'update': set this status.",
},
role: {
type: "string",
enum: [
"writer",
"producer",
"topliner",
"artist",
"engineer",
"mentor",
"organiser",
"observer",
"other",
],
description: "action 'list': filter to this role. action 'add'/'update': set this role.",
},
person_id: {
type: "string",
description: "Person ID to add. Required for action 'add' unless participants[] is used.",
},
participants: {
type: "array",
description: "Bulk-add multiple participants in one call. Each: { person_id (required), role?, notes? }. action 'add' only.",
items: {
type: "object",
properties: {
person_id: { type: "string" },
role: { type: "string" },
notes: { type: "string" },
},
required: ["person_id"],
},
},
notes: {
type: "string",
description: "Notes about this participant. action 'add'/'update'.",
},
participant_id: {
type: "string",
description: "The participant's own ID (as returned by action 'list'). Required for action 'update' and 'remove'.",
},
},
required: ["project_id", "action"],
},
},
executor: this.manageParticipants.bind(this),
},
{
definition: {
name: "pica_projects_multimedia",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Manage multimedia linked to a project (photos, videos, audio).\n\n" +
"action: 'list' (default) — every item linked to project_id, optionally " +
"filtered to one project_day.\n" +
"action: 'add' — link one item via multimedia_id (+ optional project_day, notes, " +
"allow_download), or several at once via multimedia_ids: [string, ...].\n" +
"action: 'update' — update project_day/notes/display_order/allow_download for " +
"multimedia_link_id.\n" +
"action: 'remove' — unlink multimedia_link_id from the project (the multimedia " +
"item itself is not deleted).\n\n" +
"→ then: pica_multimedia_search (find a multimedia_id to add)\n" +
"To attach works rather than media, use pica_projects_attach_works.",
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: {
project_id: { type: "string", description: "Project ID." },
action: {
type: "string",
enum: ["list", "add", "update", "remove"],
description: "Which operation to perform. Default 'list'.",
},
project_day: {
type: "number",
description: "action 'list': filter to this day. action 'add'/'update': set this day (1-indexed).",
},
multimedia_id: {
type: "string",
description: "Multimedia item ID to add. Required for action 'add' unless multimedia_ids[] is used.",
},
multimedia_ids: {
type: "array",
description: "Bulk-add multiple multimedia items in one call. action 'add' only.",
items: { type: "string" },
},
notes: {
type: "string",
description: "Notes about this multimedia link. action 'add'/'update'.",
},
allow_download: {
type: "boolean",
description: "Whether project participants can download this item. action 'add'/'update'.",
},
display_order: {
type: "number",
description: "Display order within the project. action 'update' only.",
},
multimedia_link_id: {
type: "string",
description: "The project_multimedia link's own ID (as returned by action 'list' as link_id). Required for action 'update' and 'remove'.",
},
},
required: ["project_id", "action"],
},
},
executor: this.manageMultimedia.bind(this),
},
];
}
async listProjects(args) {
const result = await this.pica.projects.list({
limit: args.limit,
});
const projects = Array.isArray(result) ? result : [];
return formatStructuredList(projects, "project");
}
async getProject(args) {
const _id = args.id ?? args.project_id;
const project = await this.pica.projects.get(_id);
return formatSuccess("Project retrieved", project);
}
async createProject(args) {
const { id, ...data } = args;
const project = await this.pica.projects.create(data);
return formatSuccess("Project created", project);
}
async updateProject(args) {
const { id, ...updates } = args;
const project = await this.pica.projects.update(id, updates);
return formatSuccess("Project updated", project);
}
async deleteProject(args) {
await this.pica.projects.delete(args.id);
return formatSuccess(`Project ${args.id} deleted`);
}
async attachOrDetachWorks(args) {
if (args.detach)
return this.detachWorks(args);
return this.attachWorks(args);
}
async attachWorks(args) {
const projectId = args.project_id;
const works = args.works ?? [];
const attached = [];
const skipped = [];
const failed = [];
for (const item of works) {
try {
const res = await this.pica.projects.attachWork(projectId, {
work_id: item.work_id,
project_day: item.project_day ?? null,
notes: item.notes ?? null,
});
attached.push({
work_id: item.work_id,
project_work_id: res?.data?.id ?? res?.id ?? "",
});
}
catch (err) {
const message = err?.message ?? (typeof err === "string" ? err : "unknown error");
if (/already linked/i.test(message)) {
skipped.push({ work_id: item.work_id, reason: "already_linked" });
}
else {
failed.push({ work_id: item.work_id, error: message });
}
}
}
return formatSuccess(`Attached ${attached.length}/${works.length} work(s) to project ${projectId}` +
(skipped.length ? ` — ${skipped.length} skipped` : "") +
(failed.length ? ` — ${failed.length} failed` : ""), { project_id: projectId, attached, skipped, failed });
}
/**
* ADR-289 Wave B2 item 2 — `detach: true` mode, ports the bespoke
* assistant's `remove_work_from_project`. Removes the `project_works`
* junction row for each listed work; the work itself is untouched.
*/
async detachWorks(args) {
const projectId = args.project_id;
const works = args.works ?? [];
const detached = [];
const failed = [];
for (const item of works) {
try {
await this.pica.projects.detachWork(projectId, item.work_id);
detached.push(item.work_id);
}
catch (err) {
const message = err?.message ?? (typeof err === "string" ? err : "unknown error");
failed.push({ work_id: item.work_id, error: message });
}
}
return formatSuccess(`Detached ${detached.length}/${works.length} work(s) from project ${projectId}` +
(failed.length ? ` — ${failed.length} failed` : ""), { project_id: projectId, detached, failed });
}
static errorResult(text) {
return { content: [{ type: "text", text }], isError: true };
}
/**
* ADR-289 Wave C1 — action-dispatched participant management, ports the
* bespoke assistant's add_participants / update_participant /
* remove_participant / list_project_participants / get_participant_details
* quintet.
*
* Unlike pica_artist_projects (Wave B2), an unrecognized action string is
* explicitly rejected rather than silently falling through to 'list'.
*/
async manageParticipants(args) {
const projectId = args.project_id;
if (!projectId) {
return ProjectsTools.errorResult("Error: project_id is required");
}
const action = args.action ?? "list";
if (action === "list") {
const result = await this.pica.projects.listParticipants(projectId);
let items = Array.isArray(result) ? result : (result?.data ?? []);
if (args.status)
items = items.filter((p) => p.status === args.status);
if (args.role)
items = items.filter((p) => p.role === args.role);
return formatStructuredList(items, "participant", {
total: items.length,
project_id: projectId,
});
}
if (action === "add") {
if (Array.isArray(args.participants) && args.participants.length > 0) {
const result = await this.pica.projects.addParticipantsBulk(projectId, args.participants);
return formatSuccess(`Added participants to project ${projectId}`, result);
}
if (!args.person_id) {
return ProjectsTools.errorResult("Error: person_id (or participants[]) is required for action 'add'");
}
const result = await this.pica.projects.addParticipant(projectId, {
person_id: args.person_id,
role: args.role,
notes: args.notes,
});
return formatSuccess(`Participant added to project ${projectId}`, result);
}
if (action === "update") {
if (!args.participant_id) {
return ProjectsTools.errorResult("Error: participant_id is required for action 'update'");
}
const result = await this.pica.projects.updateParticipant(projectId, args.participant_id, { role: args.role, status: args.status, notes: args.notes });
return formatSuccess(`Participant ${args.participant_id} updated`, result);
}
if (action === "remove") {
if (!args.participant_id) {
return ProjectsTools.errorResult("Error: participant_id is required for action 'remove'");
}
// The DELETE route keys off person_id, not the participant row's own
// id (see ProjectsResource.removeParticipant in the SDK) — resolve
// participant_id → person_id via the list, same lookup the bespoke
// handleRemoveParticipant performed.
const list = await this.pica.projects.listParticipants(projectId);
const items = Array.isArray(list) ? list : (list?.data ?? []);
const match = items.find((p) => p.id === args.participant_id);
if (!match) {
return ProjectsTools.errorResult(`Error: participant ${args.participant_id} not found in project ${projectId}`);
}
await this.pica.projects.removeParticipant(projectId, match.person_id);
return formatSuccess(`Participant ${args.participant_id} removed from project ${projectId}`, { project_id: projectId, participant_id: args.participant_id });
}
return ProjectsTools.errorResult(`Error: unrecognized action '${action}'. Valid actions: list, add, update, remove.`);
}
/**
* ADR-289 Wave C1 — action-dispatched multimedia management, ports the
* bespoke assistant's list_project_multimedia / add_multimedia_to_project /
* update_project_multimedia / remove_multimedia_from_project quartet.
*
* Unlike pica_artist_projects (Wave B2), an unrecognized action string is
* explicitly rejected rather than silently falling through to 'list'.
*/
async manageMultimedia(args) {
const projectId = args.project_id;
if (!projectId) {
return ProjectsTools.errorResult("Error: project_id is required");
}
const action = args.action ?? "list";
if (action === "list") {
const result = await this.pica.projects.listMultimedia(projectId, args.project_day);
const items = Array.isArray(result)
? result
: (result?.data ?? []);
return formatStructuredList(items, "multimedia link", {
total: items.length,
project_id: projectId,
});
}
if (action === "add") {
if (Array.isArray(args.multimedia_ids) &&
args.multimedia_ids.length > 0) {
const result = await this.pica.projects.addMultimediaBulk(projectId, args.multimedia_ids, {
project_day: args.project_day,
allow_download: args.allow_download,
});
return formatSuccess(`Added multimedia to project ${projectId}`, result);
}
if (!args.multimedia_id) {
return ProjectsTools.errorResult("Error: multimedia_id (or multimedia_ids[]) is required for action 'add'");
}
const result = await this.pica.projects.addMultimedia(projectId, {
multimedia_id: args.multimedia_id,
project_day: args.project_day,
notes: args.notes,
allow_download: args.allow_download,
});
return formatSuccess(`Multimedia added to project ${projectId}`, result);
}
if (action === "update") {
if (!args.multimedia_link_id) {
return ProjectsTools.errorResult("Error: multimedia_link_id is required for action 'update'");
}
const result = await this.pica.projects.updateMultimediaLink(projectId, args.multimedia_link_id, {
project_day: args.project_day,
notes: args.notes,
display_order: args.display_order,
allow_download: args.allow_download,
});
return formatSuccess(`Multimedia link ${args.multimedia_link_id} updated`, result);
}
if (action === "remove") {
if (!args.multimedia_link_id) {
return ProjectsTools.errorResult("Error: multimedia_link_id is required for action 'remove'");
}
// The DELETE route keys off multimedia_id (the multimedia_items id),
// not the project_multimedia join row's own id (see
// ProjectsResource.removeMultimediaLink in the SDK) — resolve
// multimedia_link_id → multimedia_id via the list, same lookup the
// bespoke handleRemoveMultimediaFromProject performed.
const list = await this.pica.projects.listMultimedia(projectId);
const items = Array.isArray(list) ? list : (list?.data ?? []);
const match = items.find((m) => m.id === args.multimedia_link_id);
if (!match) {
return ProjectsTools.errorResult(`Error: multimedia link ${args.multimedia_link_id} not found in project ${projectId}`);
}
await this.pica.projects.removeMultimediaLink(projectId, match.multimedia_id);
return formatSuccess(`Multimedia link ${args.multimedia_link_id} removed from project ${projectId}`, { project_id: projectId, multimedia_link_id: args.multimedia_link_id });
}
return ProjectsTools.errorResult(`Error: unrecognized action '${action}'. Valid actions: list, add, update, remove.`);
}
}
//# sourceMappingURL=projects.js.map