UNPKG

@withpica/mcp-server

Version:

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

522 lines • 25.1 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { formatAsText, formatList, formatSuccess } from "@withpica/mcp-utils"; /** An enquiry's budget for display; NULL means none was given (2026-09-25). */ function budgetLabel(e) { if (e.proposed_budget_amount == null) return "no budget given"; return `${e.proposed_budget_currency || "GBP"} ${e.proposed_budget_amount}`; } export class LicensingTools { pica; constructor(pica) { this.pica = pica; } /** * Get all licensing tools */ getTools() { return [ { definition: { name: "pica_search_for_sync", scope: "read:analytics", audiences: ["stdio", "http", "public"], tier: "read", description: "Search for licensable tracks by audio characteristics. " + "This is a powerful sync licensing search that finds tracks based on " + "mood, genre, BPM, musical key, energy level, and other audio features. " + "Use this when a music supervisor or client is looking for specific types of music.", 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: { mood: { type: "string", description: "Comma-separated mood tags to search for (e.g., 'uplifting,energetic' or 'melancholic,introspective')", }, genre: { type: "string", description: "Genre to filter by (e.g., 'electronic', 'acoustic', 'orchestral')", }, min_bpm: { type: "number", description: "Minimum BPM (beats per minute)", }, max_bpm: { type: "number", description: "Maximum BPM (beats per minute)", }, key: { type: "string", description: "Musical key to filter by (e.g., 'C', 'Am', 'F#', 'Bb')", }, min_energy: { type: "number", description: "Minimum energy (0.0 to 1.0) — measured loudness, 1.0 = loudest; not a mood", }, max_energy: { type: "number", description: "Maximum energy level (0.0 to 1.0)", }, min_valence: { type: "number", description: "Range filter on measured danceability (0.0 to 1.0) — despite the name, valence is not measured and the route filters danceability", }, max_valence: { type: "number", description: "Upper bound of the same danceability range (0.0 to 1.0)", }, min_duration: { type: "number", description: "Minimum duration in seconds", }, max_duration: { type: "number", description: "Maximum duration in seconds", }, instrumental_only: { type: "boolean", description: "Only return instrumental tracks (no vocals)", }, available_for_sync: { type: "boolean", description: "Only return tracks marked as available for sync licensing", }, limit: { type: "number", minimum: 1, maximum: 100, description: "Maximum number of results (default: 20, max: 100)", }, }, }, }, executor: this.searchForSync.bind(this), }, { definition: { name: "pica_submit_license_enquiry", scope: "admin", audiences: ["stdio", "http", "public"], tier: "write", description: "Record a licensing enquiry against one of your own works, e.g. one " + "that arrived by email or phone. Captures who asked, the project, " + "territory, term and (optionally) a proposed budget. Sends no email: " + "it only records the enquiry so it can be tracked and answered.", 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: { work_id: { type: "string", description: "ID of the work to license", }, track_title: { type: "string", description: "Title of the track (for reference)", }, contact_name: { type: "string", description: "Name of the person making the enquiry", }, contact_email: { type: "string", description: "Email address for contact", }, company_name: { type: "string", description: "Company or organization name (optional)", }, project_type: { type: "string", description: "Type of project", enum: [ "tv_ad", "film", "social_media", "game", "podcast", "corporate", "other", ], }, project_description: { type: "string", description: "Description of how the music will be used in the project", }, territory: { type: "string", description: "Geographic territory for the license (e.g., 'uk', 'eu', 'worldwide', 'north_america')", }, duration: { type: "string", description: "License duration (e.g., '1_year', '3_years', 'perpetual')", }, distribution: { type: "array", description: "Distribution channels (e.g., ['streaming', 'broadcast', 'cinema', 'social', 'web'])", items: { type: "string", }, }, proposed_budget_amount: { type: "number", description: "Proposed license fee amount (optional; omit if no budget was given)", }, proposed_budget_currency: { type: "string", description: "Currency for the budget (e.g., 'GBP', 'USD', 'EUR')", }, budget_notes: { type: "string", description: "Additional notes about the budget or negotiation flexibility", }, }, required: [ "work_id", "track_title", "contact_name", "contact_email", "project_type", "project_description", "territory", "duration", ], }, }, executor: this.submitLicenseEnquiry.bind(this), }, { definition: { name: "pica_list_license_enquiries", scope: "read:analytics", audiences: ["stdio", "http", "public"], tier: "read", description: "List license enquiries with optional filters. View pending, reviewed, " + "quoted, accepted, or declined enquiries.", 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: { work_id: { type: "string", description: "Filter by work ID", }, status: { type: "string", description: "Filter by status", enum: [ "pending", "reviewing", "quoted", "accepted", "declined", "expired", ], }, project_type: { type: "string", description: "Filter by project type", enum: [ "tv_ad", "film", "social_media", "game", "podcast", "corporate", "other", ], }, territory: { type: "string", description: "Filter by territory", }, limit: { type: "number", minimum: 1, maximum: 200, description: "Maximum number of results (default: 50)", }, offset: { type: "number", description: "Pagination offset (default: 0)", }, }, }, }, executor: this.listLicenseEnquiries.bind(this), }, { definition: { name: "pica_get_license_enquiry", scope: "read:analytics", audiences: ["stdio", "http", "public"], tier: "read", description: "Get details of a specific license enquiry by ID. For a filtered " + "list, use pica_list_license_enquiries; to reply with a status or " + "counter offer, use pica_update_license_enquiry_status.", 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: "License enquiry ID", }, }, required: ["id"], }, }, executor: this.getLicenseEnquiry.bind(this), }, { definition: { name: "pica_update_license_enquiry_status", // Rescoped admin → write:agreements (ADR-289 Wave C1 review): // ADMIN_SEGMENT_WRITE_FLOOR["license-enquiries"] = "write:agreements", // and admin is excluded from GRANT_EDITABLE_SCOPES, so the Wave B1 // counter-offer fields were unreachable by the in-app assistant grant. scope: "write:agreements", audiences: ["stdio", "http", "public"], tier: "write", description: "Update the status of a license enquiry, or set a counter " + "offer. Use status to move enquiries through the workflow " + "(reviewing, quoted, accepted, declined). Pass " + "counter_offer_amount to propose a different price than the " + "requester asked for — this also moves the enquiry to status " + "'quoted' automatically, so you don't need to pass status too " + "when setting a counter offer. Find the enquiry first with " + "pica_list_license_enquiries or pica_get_license_enquiry; this " + "one only writes the reply.", 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.", // status and counter_offer_amount are two independent write modes — // at least one is required (mirrors the id/work_id alias pattern // on pica_works_inspect, requiredOneOf § index.ts). requiredOneOf: [["status", "counter_offer_amount"]], inputSchema: { type: "object", properties: { id: { type: "string", description: "License enquiry ID", }, status: { type: "string", description: "New status", enum: [ "pending", "reviewing", "quoted", "accepted", "declined", "expired", ], }, notes: { type: "string", description: "Internal notes about the status change. Ignored when counter_offer_amount is set — use counter_offer_notes instead.", }, counter_offer_amount: { type: "number", description: "Counter-offer amount to propose in place of the requested price. Setting this moves the enquiry to status 'quoted' — no need to also pass status.", }, counter_offer_notes: { type: "string", description: "Internal notes recorded alongside the counter offer.", }, }, required: ["id"], }, }, executor: this.updateLicenseEnquiryStatus.bind(this), }, ]; } /** * Search for sync-licensable tracks */ async searchForSync(args) { const result = await this.pica.licensing.searchForSync({ mood: args.mood, genre: args.genre, min_bpm: args.min_bpm, max_bpm: args.max_bpm, key: args.key, min_energy: args.min_energy, max_energy: args.max_energy, min_valence: args.min_valence, max_valence: args.max_valence, min_duration: args.min_duration, max_duration: args.max_duration, instrumental_only: args.instrumental_only, available_for_sync: args.available_for_sync, limit: args.limit, }); // Format tracks for display const formattedTracks = result.tracks.map((track) => ({ id: track.id, title: track.title, work: track.work?.title || "No linked work", artist: track.work?.primary_artist || "Unknown", bpm: track.detected_bpm ? `${Math.round(track.detected_bpm)} BPM` : "Unknown", key: track.detected_key || "Unknown", energy: track.detected_energy ? `${Math.round(track.detected_energy * 100)}%` : "Unknown", mood: track.detected_mood?.join(", ") || "Not analyzed", duration: track.duration_seconds ? `${Math.floor(track.duration_seconds / 60)}:${String(Math.floor(track.duration_seconds % 60)).padStart(2, "0")}` : "Unknown", available: track.published ? "Yes" : "No", })); return formatList(formattedTracks, { title: "Sync Licensing Search Results", total: result.count, filters: result.filters, }); } /** * Submit a license enquiry */ async submitLicenseEnquiry(args) { const enquiry = await this.pica.licensing.submitEnquiry({ work_id: args.work_id, track_title: args.track_title, contact_name: args.contact_name, contact_email: args.contact_email, company_name: args.company_name, project_type: args.project_type, project_description: args.project_description, territory: args.territory, duration: args.duration, distribution: args.distribution, proposed_budget_amount: args.proposed_budget_amount, proposed_budget_currency: args.proposed_budget_currency, budget_notes: args.budget_notes, }); return formatSuccess("License enquiry submitted successfully", { id: enquiry.id, track: enquiry.track_title, project_type: enquiry.project_type, territory: enquiry.territory, proposed_budget: budgetLabel(enquiry), status: enquiry.status, }); } /** * List license enquiries */ async listLicenseEnquiries(args) { // listEnquiriesPage carries the route's real total (listEnquiries() // returned only the page, so "total" was the page length — row-cap T3). const page = await this.pica.licensing.listEnquiriesPage({ work_id: args.work_id, status: args.status, project_type: args.project_type, territory: args.territory, limit: args.limit || 50, offset: args.offset || 0, }); // Format enquiries for display const formattedEnquiries = page.data.map((enquiry) => ({ id: enquiry.id, track: enquiry.track_title, contact: `${enquiry.contact_name} (${enquiry.contact_email})`, company: enquiry.company_name || "Individual", project: enquiry.project_type, territory: enquiry.territory, budget: budgetLabel(enquiry), status: enquiry.status, created: new Date(enquiry.created_at).toLocaleDateString(), })); return formatList(formattedEnquiries, { title: "License Enquiries", total: page.total, has_more: page.hasMore, }); } /** * Get license enquiry by ID */ async getLicenseEnquiry(args) { const enquiry = await this.pica.licensing.getEnquiry(args.id); const formatted = { id: enquiry.id, track: enquiry.track_title, work_id: enquiry.work_id, contact: { name: enquiry.contact_name, email: enquiry.contact_email, company: enquiry.company_name, }, project: { type: enquiry.project_type, description: enquiry.project_description, territory: enquiry.territory, duration: enquiry.duration, distribution: enquiry.distribution, }, budget: { proposed: budgetLabel(enquiry), counter_offer: enquiry.your_counter_offer ? `${enquiry.proposed_budget_currency} ${enquiry.your_counter_offer}` : null, notes: enquiry.budget_notes, }, status: enquiry.status, internal_notes: enquiry.your_notes, responded_at: enquiry.responded_at, created_at: enquiry.created_at, }; return formatAsText(formatted); } /** * Update license enquiry status, or set a counter offer * (ADR-289 Wave B1 item 6 — ports the bespoke assistant's * `set_license_counter_offer`). */ async updateLicenseEnquiryStatus(args) { if (args.counter_offer_amount !== undefined && args.counter_offer_amount !== null) { const enquiry = await this.pica.licensing.setLicenseEnquiryCounterOffer(args.id, args.counter_offer_amount, args.counter_offer_notes); return formatSuccess("License enquiry counter offer set", { id: enquiry.id, track: enquiry.track_title, status: enquiry.status, counter_offer_amount: enquiry.your_counter_offer, notes: enquiry.your_notes, }); } if (!args.status) { return { content: [ { type: "text", text: "status or counter_offer_amount is required.", }, ], isError: true, }; } const enquiry = await this.pica.licensing.updateEnquiryStatus(args.id, args.status, args.notes); return formatSuccess("License enquiry status updated", { id: enquiry.id, track: enquiry.track_title, status: enquiry.status, notes: enquiry.your_notes, }); } } //# sourceMappingURL=licensing.js.map