UNPKG

@withpica/mcp-server

Version:

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

578 lines • 31.5 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /** * Physical Assets Tools - MCP tools for managing equipment, instruments, and studio gear */ import { EntityNotFoundError, ApiError } from "@withpica/mcp-sdk"; import { formatAsText, formatSuccess, formatStructuredList, withBillingGate, pushSectionError, PHYSICAL_ASSETS_WRITE_PROPERTIES, PHYSICAL_ASSETS_CREATE_REQUIRED, PHYSICAL_ASSETS_UPDATE_REQUIRED, } from "@withpica/mcp-utils"; export class AssetsTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_physical_assets_query", scope: "read:files", audiences: ["stdio", "http", "public"], tier: "read", description: "Find physical assets — equipment, instruments, studio gear. Filter by category. → then: pica_physical_assets_inspect (details for a specific asset)", 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: { category: { type: "string", description: "Filter by category (e.g. instrument, equipment, studio, software)", }, limit: { type: "number", description: "Max results (default 50)", }, }, }, }, executor: this.listAssets.bind(this), }, { definition: { name: "pica_physical_assets_inspect", scope: "read:files", audiences: ["stdio", "http", "public"], tier: "read", description: "Get details for a specific physical asset. Omit sections for basic, " + "valuations and links. " + "Sections: basic, valuations, market, links (work + recording link rows " + "written by pica_physical_assets_link_work / _link_recording with their " + "relationship + evidence + confidence fields). " + "`market` is an AI-generated price estimate from the asset's make and " + "model, not a recorded valuation: it runs only when listed by name, " + "and needs a make or model on the asset. It is a POST to the " + "assets area, so a key or grant needs write:catalog (or admin) " + "for it; with read access only, `market` comes back as a section " + "error and the other sections still answer. " + "→ then: pica_physical_assets_update (modify), pica_physical_assets_export (for insurance)", 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: "Asset ID", }, sections: { type: "array", items: { type: "string", enum: ["basic", "valuations", "market", "links"], }, description: "Which data sections to include. Omit for basic, valuations and links; `market` must be named.", }, }, required: ["id"], }, }, executor: this.inspectAsset.bind(this), }, { definition: { name: "pica_physical_assets_create", scope: "write:files", audiences: ["stdio", "http", "public"], tier: "write", description: "Register a new physical asset — instrument, equipment, software, " + "studio space, or other (the only accepted asset types). Tracks " + "identity, condition, acquisition, photos, and status as fields of " + "their own. Valuation and insurance details belong inside " + "`external_references`, a free-form field (see key enumeration on it). " + "Pair with pica_upload (accept: 'image') to capture photo evidence via " + "a drop zone. " + "Prefer pica_physical_assets_update when the asset already exists; this one creates a new record.", 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: PHYSICAL_ASSETS_WRITE_PROPERTIES, required: [...PHYSICAL_ASSETS_CREATE_REQUIRED], }, }, executor: withBillingGate(this.pica, "physical asset creation", this.createAsset.bind(this)), }, { definition: { name: "pica_physical_assets_update", scope: "write:files", audiences: ["stdio", "http", "public"], tier: "write", description: "Update a physical asset. All create fields are optional on update; " + "`id` is required. Valuation and insurance changes go through " + "`external_references` (current_valuation, valuation_source, " + "reverb_listing_url, insurer, insured_value, etc.) — the asset itself " + "carries no top-level valuation field. Pair with pica_upload " + "(accept: 'image') to add more photo evidence. " + "Prefer pica_physical_assets_create when no asset record exists yet.", 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: "Asset ID" }, ...PHYSICAL_ASSETS_WRITE_PROPERTIES, }, required: [...PHYSICAL_ASSETS_UPDATE_REQUIRED], }, }, executor: withBillingGate(this.pica, "physical asset update", this.updateAsset.bind(this)), }, { definition: { name: "pica_physical_assets_delete", scope: "write:files", audiences: ["stdio", "http", "public"], tier: "destructive", description: "Delete a physical asset by ID. " + "To withdraw an asset's link to a work rather than the asset itself, use pica_physical_assets_unlink_work.", 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: "Asset ID" }, }, required: ["id"], }, }, executor: withBillingGate(this.pica, "physical asset deletion", this.deleteAsset.bind(this)), }, { definition: { name: "pica_physical_assets_stats", scope: "read:files", audiences: ["stdio", "http", "public"], tier: "read", description: "Get summary statistics for all physical assets — total value, count by category, insurance coverage. " + "Prefer pica_physical_assets_query to list the assets themselves; this one returns totals only.", 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: {} }, }, executor: this.assetStats.bind(this), }, { definition: { name: "pica_physical_assets_export", scope: "read:files", audiences: ["stdio", "http", "public"], tier: "read", description: "Export all physical assets as a downloadable report. " + "Prefer pica_physical_assets_stats for a summary without a file.", 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: {} }, }, executor: this.assetExport.bind(this), }, // --- Production-asset links (ADR-156 operating principle #3) --- // Typed edges between physical_assets and works/recordings with // rich provenance. Works-side and recordings-side relationship // enums deliberately diverge — works describe process, recordings // describe physical performance. A guitar is "played on" a // recording, not "played on" a composition. Unlink is soft // (confidence='withdrawn') to preserve the audit trail. { definition: { name: "pica_physical_assets_link_work", scope: "write:files", audiences: ["stdio", "http", "public"], tier: "write", description: "Link a physical asset to a work (composition). Works describe process — vocabulary captures how the asset was used in making the composition. " + "Relationship enum: used_in_production | recorded_with | mastered_with | mixed_with. " + "Evidence type (optional): user_declared | session_log | receipt | photo | document | imported | inferred. " + "Returns the created link row with confidence='asserted'. " + "→ for artefact-level claims (e.g. 'this guitar was played on this recording') use pica_physical_assets_link_recording instead.", 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: { assetId: { type: "string", description: "Physical asset ID" }, workId: { type: "string", description: "Work ID to link to" }, relationship: { type: "string", enum: [ "used_in_production", "recorded_with", "mastered_with", "mixed_with", ], description: "How the asset relates to the work (default: used_in_production)", }, evidenceType: { type: "string", enum: [ "user_declared", "session_log", "receipt", "photo", "document", "imported", "inferred", ], description: "Evidence source backing this claim (optional)", }, evidenceDocumentId: { type: "string", description: "Documents table ID if evidence is a stored file (optional)", }, evidenceUrl: { type: "string", description: "External evidence URL (optional)", }, notes: { type: "string", description: "Free-text context for the link (optional)", }, }, required: ["assetId", "workId"], }, }, executor: withBillingGate(this.pica, "physical asset link", this.linkAssetToWork.bind(this)), }, { definition: { name: "pica_physical_assets_link_recording", scope: "write:files", audiences: ["stdio", "http", "public"], tier: "write", description: "Link a physical asset to a recording (captured performance). Recordings describe artefact — vocabulary captures the physical relationship between the object and the captured moment. " + "Relationship enum: instrument_played_on | master_tape_of | session_document_for | mixed_on | mastered_on | recorded_at. " + "The master_tape_of relationship (many-to-one: safety copies, remasters, regional masters) lives here rather than as a recordings column so each physical master can carry its own provenance. " + "Evidence type (optional): user_declared | session_log | receipt | photo | document | imported | inferred. " + "Returns the created link row with confidence='asserted'. " + "→ for process-level claims against a composition use pica_physical_assets_link_work instead.", 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: { assetId: { type: "string", description: "Physical asset ID" }, recordingId: { type: "string", description: "Recording ID to link to", }, relationship: { type: "string", enum: [ "instrument_played_on", "master_tape_of", "session_document_for", "mixed_on", "mastered_on", "recorded_at", ], description: "How the asset relates to the recording (default: instrument_played_on)", }, evidenceType: { type: "string", enum: [ "user_declared", "session_log", "receipt", "photo", "document", "imported", "inferred", ], description: "Evidence source backing this claim (optional)", }, evidenceDocumentId: { type: "string", description: "Documents table ID if evidence is a stored file (optional)", }, evidenceUrl: { type: "string", description: "External evidence URL (optional)", }, notes: { type: "string", description: "Free-text context for the link (optional)", }, }, required: ["assetId", "recordingId"], }, }, executor: withBillingGate(this.pica, "physical asset link", this.linkAssetToRecording.bind(this)), }, { definition: { name: "pica_physical_assets_unlink_work", scope: "write:files", audiences: ["stdio", "http", "public"], tier: "write", description: "Soft-unlink a physical asset from a work. Sets the link's confidence to 'withdrawn' rather than deleting the row, preserving the audit trail of retracted claims (rich provenance includes history, not just current state). " + "For a recording link rather than a work link, use pica_physical_assets_unlink_recording.", 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: { assetId: { type: "string", description: "Physical asset ID" }, linkId: { type: "string", description: "ID of the recording_production_assets link row to withdraw", }, }, required: ["assetId", "linkId"], }, }, executor: withBillingGate(this.pica, "physical asset unlink", this.unlinkWorkAsset.bind(this)), }, { definition: { name: "pica_physical_assets_unlink_recording", scope: "write:files", audiences: ["stdio", "http", "public"], tier: "write", description: "Soft-unlink a physical asset from a recording. Sets confidence to 'withdrawn'; the row is preserved for the audit trail. " + "For a work link rather than a recording link, use pica_physical_assets_unlink_work.", 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: { assetId: { type: "string", description: "Physical asset ID" }, linkId: { type: "string", description: "ID of the recording_production_assets row to withdraw", }, }, required: ["assetId", "linkId"], }, }, executor: withBillingGate(this.pica, "physical asset unlink", this.unlinkRecordingAsset.bind(this)), }, ]; } async listAssets(args) { const assets = await this.pica.assets.list({ category: args.category, limit: args.limit, }); return formatStructuredList(Array.isArray(assets) ? assets : assets?.data || [], "asset"); } async inspectAsset(args) { const id = args.id; const sections = args.sections; const allSections = !sections || sections.length === 0; const results = {}; // ADR-224 — see works.ts for the full doctrine. Each section catch // pushes to errors[] + emits stderr via pushSectionError; the flag // governs whether the public response carries the structured shape // (null + errors[]) or the legacy `{ error: msg }` object-replacement // (which breaks the field's type contract — agents expecting // `valuations: Valuation[]` got `valuations: { error }`). const useStructured = process.env.ADR_224_STRUCTURED_ERRORS === "1"; const errors = []; const TOOL_NAME = "pica_physical_assets_inspect"; const promises = []; if (allSections || sections?.includes("basic")) { promises.push((async () => { try { results.basic = await this.pica.assets.get(id); } catch (e) { // The basic section IS the primary entity. A 404 (missing/foreign // asset) or a 400 (invalid id) here is not a degraded section — // fail-softing them returned a success envelope with the failure // buried in basic.error (ADR-198 doctrine; the works_inspect // 2026-07-18 fix named the sibling inspectors as open follow-up). // Rethrowing inside this async block rejects the Promise.all // below, so the whole call surfaces a proper error envelope + // the pica_physical_assets_inspect recovery hint. if (e instanceof EntityNotFoundError) { throw e; } if (e instanceof ApiError && e.status === 400) { throw e; } results.basic = useStructured ? null : { error: e instanceof Error ? e.message : "Failed to fetch asset", }; pushSectionError(errors, TOOL_NAME, "basic", e, id); } })()); } if (allSections || sections?.includes("valuations")) { promises.push((async () => { try { results.valuations = await this.pica.assets.valuations(id); } catch (e) { results.valuations = useStructured ? null : { error: e instanceof Error ? e.message : "Failed to fetch valuations", }; pushSectionError(errors, TOOL_NAME, "valuations", e, id); } })()); } // `market` is not part of "everything". The route behind it // (POST /api/admin/assets/[id]/market-check) asks an AI model for a price // estimate on every call, so it runs only when the caller names it. // Regression: "should not run the market check when sections omitted" // in mcp-server/src/__tests__/tools/assets.test.ts. if (sections?.includes("market")) { promises.push((async () => { try { results.market = await this.pica.assets.marketCheck(id); } catch (e) { results.market = useStructured ? null : { error: e instanceof Error ? e.message : "Failed to fetch market data", }; pushSectionError(errors, TOOL_NAME, "market", e, id); } })()); } if (allSections || sections?.includes("links")) { // ADR-201 Phase 2: surface the junction-table rows that // pica_physical_assets_link_work / _link_recording write. The two // GET endpoints (admin/assets/[id]/{work,recording}-links) already // existed but had no MCP read surface, so link rows were write-only. promises.push((async () => { try { const [worksResult, recordingsResult] = await Promise.all([ this.pica.assets.listWorkLinks(id), this.pica.assets.listRecordingLinks(id), ]); results.links = { works: worksResult?.data ?? worksResult ?? [], recordings: recordingsResult?.data ?? recordingsResult ?? [], }; } catch (e) { results.links = useStructured ? null : { error: e instanceof Error ? e.message : "Failed to fetch links", }; pushSectionError(errors, TOOL_NAME, "links", e, id); } })()); } await Promise.all(promises); const name = results.basic?.name || id; const enriched = useStructured ? { ...results, errors } : results; return formatSuccess(`Asset: ${name}`, enriched); } async createAsset(args) { try { const asset = await this.pica.assets.create(args); return formatSuccess("Physical asset registered successfully", asset); } catch (e) { // Fail-quiet to ops: the framework's outer catch would JSON-stringify the // SDK error straight to the user. Catch here, surface to ops_issues for // triage, return neutral text. Auto-audit upsert is bypassed because // result_status will log as "success" — that's why we report manually. const msg = e instanceof Error ? e.message : String(e); const status = e?.status; void this.pica.opsIssues .reportIssue({ kind: "tool_failure", summary: `pica_physical_assets_create failed${status ? ` (${status})` : ""}: ${msg}`, context: { tool_name: "pica_physical_assets_create", error_code: status ? `HTTP_${status}` : "UNKNOWN", parameters_shape: Object.keys(args).sort().join(","), }, }) .catch(() => { // never block on the ops report — it's fire-and-forget }); return { content: [ { type: "text", text: "couldn't register that asset right now — the team has been alerted.", }, ], isError: true, }; } } async updateAsset(args) { const { id, ...updates } = args; const asset = await this.pica.assets.update(id, updates); return formatSuccess("Asset updated successfully", asset); } async deleteAsset(args) { await this.pica.assets.delete(args.id); return formatSuccess("Asset deleted successfully"); } async assetStats(_args) { const stats = await this.pica.assets.stats(); return formatAsText(stats); } async assetExport(_args) { const result = await this.pica.assets.exportAll(); return formatAsText(result); } // --- Production-asset link handlers (ADR-156 op #3) --- async linkAssetToWork(args) { const assetId = args.assetId; const result = await this.pica.assets.linkWork(assetId, { workId: args.workId, relationship: args.relationship, evidenceType: args.evidenceType, evidenceDocumentId: args.evidenceDocumentId, evidenceUrl: args.evidenceUrl, notes: args.notes, }); return formatSuccess("Asset linked to work", result); } async linkAssetToRecording(args) { const assetId = args.assetId; const result = await this.pica.assets.linkRecording(assetId, { recordingId: args.recordingId, relationship: args.relationship, evidenceType: args.evidenceType, evidenceDocumentId: args.evidenceDocumentId, evidenceUrl: args.evidenceUrl, notes: args.notes, }); return formatSuccess("Asset linked to recording", result); } async unlinkWorkAsset(args) { await this.pica.assets.unlinkWork(args.assetId, args.linkId); return formatSuccess("Work asset link withdrawn (confidence='withdrawn', row preserved for audit trail)"); } async unlinkRecordingAsset(args) { await this.pica.assets.unlinkRecording(args.assetId, args.linkId); return formatSuccess("Recording asset link withdrawn (confidence='withdrawn', row preserved for audit trail)"); } } //# sourceMappingURL=assets.js.map