UNPKG

@withpica/mcp-server

Version:

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

701 lines • 37.9 kB
// 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