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