UNPKG

@withpica/mcp-server

Version:

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

687 lines • 32.6 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /* eslint-disable @typescript-eslint/no-explicit-any -- ToolExecutor signature uses Record<string, any> */ /** * Subscription + billing tools — ADR-210 Phase 2, extended by the MPP pay * rail (spec 2026-08-24 WS-B). * * Three MCP tools that surface the explicit billing read + action layer * complementing the ambient `_meta.session_state.billing_slice` shipped * in Phase 1: * * - pica_subscription_status (read; readOnlyHint=true) * Wraps `GET /api/admin/subscription` and flattens it into the card * below (`SubscriptionStatusFlat`), composing a templated `summary` * line the agent uses verbatim. It is no longer the 9 fields ADR-210 * § Phase 2 specified: two trial fields came off on 2026-08-21, and * WS-B added `pricing` + `held`, each present only when the route * measured it. Read the interface, not a count. * * - pica_subscription_manage (action; readOnlyHint=false) * Single-verb dispatcher over the existing * `POST /api/admin/subscription` (subscribe / upgrade) and * `POST /api/admin/subscription/portal` (manage / cancel) routes. * Returns a `resource_link` content block (universal MCP floor) * and uses `elicitation/create url` only when the client * advertises `capabilities.elicitation.url` per ADR-200 Phase 1 * (capability-gated; `feedback_mcp_sdk_capability_gates_elicitation.md`). * * - pica_billing_pay (read; readOnlyHint=true) * Wraps `POST /api/admin/billing/pay-link`. Mints a link for one * offer — resident_month / unlock / settle — and never charges: an * agent POSTs the link with a Machine Payments credential, or a * person opens it and pays by card. The refusals the ROUTE itself * composes — billing disabled, nothing held, offer moved — come back * as structured results rather than thrown ApiErrors. A 4xx from the * auth wrapper in front of it is quoted, never re-interpreted; a 4xx * carrying nothing quotable, and everything from 500 up, is re-thrown * (`payRefusal` below is the single place that decides which). * * No new Stripe SDK calls — every route here is reused as-is. */ import { ApiError, } from "@withpica/mcp-sdk"; // ADR-205 D7: enterprise is sales-led, not bookable through Checkout. // Mirror the route-layer allowlist here for a better tool-side error // message before the request leaves the agent. const VALID_PAID_TIERS = [ "solo", "indie", "pro", "established", ]; const VALID_ACTIONS = ["subscribe", "upgrade", "manage", "cancel"]; /** * The offers `POST /api/admin/billing/pay-link` accepts. Checked here as well * as at the route because an unknown offer is a mistake the agent can fix * from the tool's own answer, without spending a round trip on a 400. */ const VALID_PAY_OFFERS = [ "resident_month", "unlock", "settle", ]; /** * Where a refused mint sends the agent next, by the route's error code. * Mirrored in `RECOVERY_HINTS.pica_billing_pay` (recovery-hints.ts), which is * what `pica_tool_details` reads; this copy is what the refusal itself * carries, so an agent that never opens tool details still gets the pointer. */ const NEXT_TOOL_FOR_PAY_ERROR = { nothing_held_for_entity: "pica_dashboard_briefing", offer_no_longer_applies: "pica_subscription_status", }; /** * The pay-link route's own flag-off body, verbatim * (`app/api/admin/billing/pay-link/route.ts`: `{ error: "billing not enabled" }`). * * Named rather than inlined because it is the ONE 403 that licenses a * statement about money, and a reader needs to be able to grep both ends of * that coupling. If the route ever reworded it, this comparison stops * matching and every 403 falls through to the access-refusal branch below — * which says less, but says nothing false. */ const BILLING_DISABLED_ERROR = "billing not enabled"; function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** The first candidate that is a non-blank string. */ function firstString(...candidates) { for (const candidate of candidates) { if (typeof candidate === "string" && candidate.trim().length > 0) { return candidate; } } return undefined; } /** The stored billing state in the vocabulary ADR-276 actually uses. */ export function planFor(billingState) { if (billingState === "active") return "resident"; if (billingState === "hibernated") return "paused"; return "free_runway"; } /** * Compose `SubscriptionStatusFlat` from the GET admin route's shape: eight * fields that are always present (`billing_state`, `plan`, `current_tier`, * `capacity_used`, `capacity_limit`, `capacity_pct`, `recommended_tier`, * `summary`) plus `pricing` and `held`, each emitted ONLY when the route * measured it. * * ⚠️ This opened by quoting a FIELD COUNT until 2026-08-26, and that count had * been wrong since 08-21 — `trial_days_remaining` and `trial_ends_at` came off * then, and WS-B added the two optional keys. A count is the one thing a * docstring cannot keep true across a field change, and it rots silently * because nothing compiles against it. Do not reintroduce one: name the fields * (above) or point at the interface, which the compiler keeps honest. * * Mirrors `getOrgBillingSlice`'s "no meaningful cap" rule for `capacity_pct` * (0 whenever current_tier is null OR enterprise) and carries through * `overflow.recommendedTier` (already `inferTierFromCount`-derived) per * Stage 1 Q2. * * Exported for unit tests. */ export function flattenStatus(status) { const capacityUsed = status.overflow?.currentCount ?? 0; const capacityLimit = status.overflow?.capacity ?? null; const recommendedTier = status.overflow?.recommendedTier ?? null; // Same rule `getOrgBillingSlice` uses — `capacity_pct` is 0 whenever // there's no meaningful cap to compare against (trial / enterprise / // no overflow row). Avoids fabricating a meaningful percentage from // the trial-time inferred recommended tier. const hasMeaningfulCap = status.tier !== null && status.tier !== "enterprise" && capacityLimit !== null && capacityLimit > 0; const capacityPct = hasMeaningfulCap ? Math.round((capacityUsed / capacityLimit) * 100) : 0; // WS-B: both arrive `null` when billing enforcement is off, and are absent // entirely against a deployment whose route predates them. Either way there // is nothing to say, so neither key is emitted. const pricing = status.pricing ?? undefined; const held = status.held ?? undefined; return { billing_state: status.billingState, plan: planFor(status.billingState), current_tier: status.tier, capacity_used: capacityUsed, capacity_limit: capacityLimit, capacity_pct: capacityPct, recommended_tier: recommendedTier, summary: composeSummary({ billing_state: status.billingState, capacity_used: capacityUsed, pricing, held, }), ...(pricing ? { pricing } : {}), ...(held ? { held } : {}), }; } /** `n` with the noun pluralised — "1 song", "7 songs". */ function plural(n, noun) { return `${n} ${noun}${n === 1 ? "" : "s"}`; } /** * The price half of the summary, built ENTIRELY from the figures the route * sent. No amount of money appears here on purpose: the money is worded once, * by the pay-link route's offer labels (`formatMoney` over the org's own * currency, whose minor-unit conventions differ from ICU's for four of the * ten supported currencies). A second wording here would be a second home for * the price — the defect FIX_LOG 2026-08-20 records as having had ten. * * Returns `null` when there is nothing measured to say, so the caller appends * nothing rather than a sentence about absence. * * Exported for unit tests. */ export function composePriceLine(pricing, held) { const clauses = []; if (pricing) { clauses.push(`Deep processing is free for your first ` + `${plural(pricing.freeRunwayDeepProcesses, "song")}, and a resident ` + `month includes ${plural(pricing.residentIncludedDeepProcesses, "song")}.`); } // count === 0 is the common case and says nothing worth a sentence; saying // "0 findings are held" invites an agent to offer a pay link nobody needs. if (held && held.count > 0) { clauses.push(`${plural(held.count, "finding")} across ` + `${plural(held.entities, "work")} are held until one of the offers ` + `is paid — ask for a pay link to see the price and how to pay it.`); } return clauses.length > 0 ? clauses.join(" ") : null; } /** * The sentence an agent says out loud. Server-side template (Stage 1 Q1 option * A) so wording does not vary across connectors. * * ⚠️ REWRITTEN 2026-08-21. Every branch was false under ADR-276: * * trial → "You're on a 9-day window of your trial. Today's catalogue * fits the solo tier." — there is no trial and no tier ladder * hibernated → "Your trial has ended and the catalogue is read-only. * Subscribe to restore writes." * — hibernation gates `pica_audio_analyze` and nothing else; * the catalogue is NOT read-only, and there is nothing to * subscribe to * active → "Currently on pro. 412 of 4,000 slots used." * — the capacity ladder was superseded * * 239 of 241 production organisations sit at `billing_state = 'trial'`, so the * first branch was what an agent said to almost every user pica has. The screen * had already been corrected; this had not, which is the recurring shape here — * see `.claude/rules/canonical-model.md` on `WORK_NO_WRITER`. * * Capacity is deliberately not mentioned: no cap is enforced under ADR-276, and * quoting a limit implies one. The work count is stated as a plain fact instead. * * Exported for unit tests. */ export function composeSummary(fields) { const { billing_state, capacity_used } = fields; const works = `${capacity_used} active work${capacity_used === 1 ? "" : "s"}`; const priceLine = composePriceLine(fields.pricing, fields.held); const withPrice = (base) => priceLine ? `${base} ${priceLine}` : base; if (billing_state === "hibernated") { return withPrice(`Deep-processing is paused on this catalogue because a cycle went unpaid. ` + `Nothing has been deleted and you can still read and export everything ` + `(${works} held). Becoming a resident again switches deep-processing back on.`); } if (billing_state === "active") { return withPrice(`You're a resident — one flat fee per 30-day cycle, nothing auto-renews. ${works}.`); } // trial === the free runway. No clock, no card, no countdown. return withPrice(`You're on the free runway: no card, no countdown, and you are not charged ` + `for holding a catalogue. ${works} so far.`); } /** * Turn a pay-link 4xx into the structured refusal an agent can act on, or * `null` when the caller must keep propagating it — 5xx, timeouts, network * failures, and any 403 whose body carries no message worth quoting. * * The 403 branch is the delicate one: exactly one 403 on this path is about * money, and the rest are about access. See its comment. * * Exported for unit tests. */ export function payRefusal(err, offer, entityId) { if (!(err instanceof ApiError)) return null; if (err.status !== 403 && err.status !== 400 && err.status !== 409) { return null; } const body = parsePayErrorBody(err); const code = typeof body.error === "string" ? body.error : undefined; if (err.status === 403) { // ⚠️ ONLY the route's own flag-off body may license a claim about money. // // This branch answered every 403 with "nothing is owed", and the route is // not the only thing on this path that answers 403. It sits behind // `withDualAuth`, whose scope denial is // `{ error: { code: "INSUFFICIENT_SCOPE", message, remedy } }` — an // OBJECT, so the string `code` above is undefined — and the // team-permission guard answers `{ error: "<message>", code: "TEAM_..." }`. // Both mean "this connection may not ask", which is not a statement about // a bill, and the agent was relaying it as one. if (code === BILLING_DISABLED_ERROR) { return { content: [ { type: "text", text: "pica is not charging on this deployment, so there is no pay link " + "to mint and nothing is owed. Everything the catalogue does is " + "available; no offer applies.", }, ], structuredContent: { kind: "pica.error.billing_not_enabled", version: 1, data: { offer, reason: BILLING_DISABLED_ERROR }, }, isError: true, }; } // Any other 403 is an ACCESS refusal. Quote the refuser's own words and // make no claim about what is or is not owed — the call never reached the // billing engine, so nothing here knows. const nested = isRecord(body.error) ? body.error : undefined; const message = firstString(nested?.message, code); // No quotable message means no honest account of WHY. Returning null hands // the error back to the executor to re-throw, exactly as a 500 is handled: // an opaque failure is better information than a confident wrong sentence. if (!message) return null; const remedy = firstString(nested?.remedy, body.remedy); return { content: [ { type: "text", text: `pica refused to mint a pay link: ${message}. That is an access ` + `refusal and says nothing about what is or is not owed.` + (remedy ? ` ${remedy}` : ""), }, ], structuredContent: { kind: "pica.error.forbidden", version: 1, data: { offer, ...(entityId ? { entity_id: entityId } : {}), error: firstString(nested?.code, body.code) ?? "forbidden", message, ...(remedy ? { next_step: remedy } : {}), retry_safe: false, }, }, isError: true, }; } const offers = Array.isArray(body.offers) ? body.offers : undefined; let text; if (code === "nothing_held_for_entity") { text = "nothing is held on that work, so there is nothing to unlock. Check " + "what is actually waiting across the catalogue before offering to pay."; } else if (code === "offer_no_longer_applies") { const list = offers?.map((o) => o.label).join("; "); text = list ? `that offer no longer applies. What is on the table now: ${list}.` : "that offer no longer applies, and no other offer applies either."; } else { text = `that pay link could not be minted: ${code ?? err.message}.`; } return { content: [{ type: "text", text }], structuredContent: { kind: "pica.error.offer_unavailable", version: 1, data: { offer, ...(entityId ? { entity_id: entityId } : {}), error: code ?? "pay_link_refused", ...(offers ? { offers } : {}), next_tool: (code ? NEXT_TOOL_FOR_PAY_ERROR[code] : undefined) ?? "pica_subscription_status", retry_safe: false, }, }, isError: true, }; } /** * Pull the route's JSON body back out of an `ApiError`. The SDK folds the * response text into the message (`"API request failed: 400 <body>"`, and * `"Authentication failed: 403 <body>"` on the auth path) rather than * carrying a structured body — the same extraction `duplicates.ts` and * `integrity.ts` do. Returns `{}` when the body is absent or not JSON, so * every caller reads a `code` of `undefined` rather than throwing twice. * * Exported for unit tests. */ export function parsePayErrorBody(err) { const match = err.message.match(/^(?:API request failed|Authentication failed):\s*\d{3}\s*([\s\S]*)$/); const text = match?.[1]?.trim(); if (!text) return {}; try { const parsed = JSON.parse(text); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } } export class SubscriptionTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ { definition: { name: "pica_subscription_status", // read:catalog on both surfaces, the same declaration as // pica_billing_pay below: a connector agent that can mint a pay // link must be able to read the plan it would be paying for. The // card carries plan, tier, capacity, public pricing and the held // count — no amounts owed — so the catalogue-read floor is the // right scope (FIX_LOG 2026-09-04). Manage stays stdio + admin. scope: "read:catalog", audiences: ["stdio", "http"], tier: "read", description: "Use when the user asks: 'what's my plan?', 'am I paying for this?', " + "'what does pica cost me?'. Read the org's current billing posture. " + "Returns `plan` — free_runway (walked in, never charged, no clock), " + "resident (paying one flat fee per 30-day cycle), or paused (a cycle " + "lapsed, so deep-processing is off) — alongside the stored " + "billing_state, the active work count, and a one-line summary to use " + "verbatim. There is NO trial and NO tier ladder: pica is free to walk " + "in and residency is opted into one cycle at a time, so never tell a " + "user their trial is ending or offer to upgrade a tier. Complement to " + "the ambient _meta.session_state.billing_slice; use this tool when an " + "explicit answer is needed.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: {}, additionalProperties: false, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, executor: this.status.bind(this), }, { definition: { name: "pica_subscription_manage", scope: "admin", audiences: ["stdio"], tier: "write", description: "Retired — every action refuses. pica no longer sells a subscription " + "or a capacity tier, so there is nothing here to subscribe to, " + "upgrade, or cancel. pica is free to walk in and use; residency is a " + "single one-off charge for a 30-day cycle that the user confirms " + "themselves, and nothing auto-renews, so there is no recurring " + "mandate to manage. Direct the user to the pricing page or their " + "billing settings instead. Kept callable only so an agent that " + "invokes it receives an explanation rather than an unknown-tool error.", 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: { action: { type: "string", enum: [...VALID_ACTIONS], description: "subscribe / upgrade open Stripe Checkout (require tier); " + "manage / cancel open Stripe Billing Portal (no tier).", }, tier: { type: "string", enum: [...VALID_PAID_TIERS], description: "Ignored. pica no longer sells capacity tiers; the enum is " + "retained only so an existing caller's argument still " + "validates rather than erroring on an unknown property.", }, currency: { type: "string", enum: ["gbp", "usd", "eur"], description: "Optional. Defaults to gbp. Honoured only on subscribe / upgrade.", }, }, required: ["action"], additionalProperties: false, }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, executor: this.manage.bind(this), }, { definition: { name: "pica_billing_pay", // read:catalog, NOT write:finances — and deliberately so. Minting a // link mints a capability, not a charge: nothing is billed until // whoever holds the link pays it, so a member fetching a link for // the owner to pay is the intended flow. The route gates the same // way (app/api/admin/billing/pay-link/route.ts). scope: "read:catalog", audiences: ["stdio", "http"], tier: "read", description: "Use when the user asks: 'how do i pay pica?', 'what does pica cost?'. " + "Also use when pica is holding findings, or a deep process was " + "refused for runway. Mints a pay link for one offer: resident_month " + "(one month of residency; shows every held finding within the " + "allowance), unlock (one work's held findings, needs entity_id), or " + "settle (a resident's tab). Never charges by itself: an agent POSTs " + "the link with a Machine Payments credential (Link agent wallet or " + "Tempo), or a person opens it in a browser and pays by card. Returns " + "the offers with amounts derived from pica's constants and the " + "current held count. Refuses when billing enforcement is off.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { offer: { type: "string", enum: [...VALID_PAY_OFFERS], description: "resident_month (a 30-day cycle), unlock (one work's held " + "findings), or settle (what a resident already owes).", }, entity_id: { type: "string", description: "The work to unlock (offer = unlock).", }, }, required: ["offer"], additionalProperties: false, }, annotations: { // readOnly is honest here: this mints a link and changes nothing // an org can see. idempotentHint is false because each mint is a // fresh token with a fresh expiry, not the same link returned twice. readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, }, executor: this.pay.bind(this), }, ]; } /** * `pica_billing_pay` — hand the caller a link, and never a charge. * * The refusals the ROUTE composes (the flag is off; nothing is held on that * work; that offer no longer applies) become structured results rather than * thrown ApiErrors — a throw reaches the agent as UNKNOWN_ERROR, which * reads as "pica is broken" when the honest answer is "there is nothing to * pay for". `payRefusal` decides; everything it declines (5xx, timeouts, * network failures, and a 403 with nothing quotable in it) is re-thrown * unchanged, because an outage dressed as a tidy refusal is the worse lie. */ async pay(args) { const offer = args.offer; if (!offer || !VALID_PAY_OFFERS.includes(offer)) { return { content: [ { type: "text", text: `Error: offer must be one of ${VALID_PAY_OFFERS.join(", ")}.`, }, ], structuredContent: { kind: "pica.error.invalid_argument", version: 1, data: { argument: "offer", valid: [...VALID_PAY_OFFERS] }, }, isError: true, }; } const entityId = typeof args.entity_id === "string" ? args.entity_id : undefined; let link; try { link = await this.pica.billing.mintPayLink({ offer, entity_id: entityId, }); } catch (err) { const refusal = payRefusal(err, offer, entityId); if (refusal) return refusal; throw err; } // The label is the ONE place the amount is worded — composed server-side // in the org's own currency. Quote it; never re-format `amount_minor`. const label = link.offers?.find((o) => o.offer === link.offer)?.label ?? link.offer; return { content: [ { type: "text", text: `${label}: ${link.pay_url} (expires ${link.expires_at}). ${link.how}`, }, ], structuredContent: { kind: "pica.card.pay_link", version: 1, data: link, }, }; } async status(_args) { const status = await this.pica.subscription.getStatus(); if (!status) { // STRIPE_SECRET_KEY unset on this deployment — preview/dev path. return { content: [ { type: "text", text: "Billing is not configured on this deployment. " + "Subscription status is unavailable.", }, ], structuredContent: { kind: "pica.card.subscription_status", version: 1, data: { billing_unavailable: true }, }, }; } const flat = flattenStatus(status); return { content: [{ type: "text", text: flat.summary }], structuredContent: { kind: "pica.card.subscription_status", version: 1, data: flat, }, }; } async manage(args) { const action = args.action; if (!action || !VALID_ACTIONS.includes(action)) { return { content: [ { type: "text", text: `Error: action must be one of ${VALID_ACTIONS.join(", ")}.`, }, ], structuredContent: { kind: "pica.error.invalid_argument", version: 1, data: { argument: "action", valid: [...VALID_ACTIONS], }, }, isError: true, }; } // ⚠️ THIS TOOL NO LONGER MINTS A CHECKOUT OR A PORTAL LINK. // // Until 2026-08-21 `action: "subscribe", tier: "indie"` minted a LIVE Stripe // Checkout for a £49/month RECURRING subscription — from an agent, in // production. The tier price IDs are configured on Production and the Stripe // prices were active, so this was a working till for a product pica stopped // selling when ADR-276 superseded ADR-205 on 2026-06-26. // // It also contradicted the Terms of Service, which state that pica does not // store a recurring mandate and does not auto-renew. A live path capable of // creating one made that sentence false. // // Nobody was ever charged through it (0 successful charges, 0 subscriptions // of any status), which is why this is a refusal and not an incident. // // Residency is charged by the billing engine when the deep-process gate // blocks — a one-off `mode: "payment"` Checkout the user confirms per cycle, // priced from RESIDENT_FEE_MINOR_UNITS with inline `price_data`. There is no // "subscribe on demand" endpoint to expose, and inventing one here would be // the same mistake in the other direction. const retiredMessage = "pica does not sell a subscription or a capacity tier — there is nothing " + "to subscribe to, upgrade, or cancel. pica is free to walk in and use: " + "holding a catalogue, registering works, browsing and searching cost " + "nothing, and there is no trial clock. If the user has gone past the free " + "deep-processing allowance, pica offers residency at that moment — one " + "small flat fee for a 30-day cycle, priced for their region, which they " + "confirm each time. Nothing auto-renews, so nothing needs cancelling. " + "Point them at the pricing page or their billing settings."; return { content: [{ type: "text", text: retiredMessage }], structuredContent: { kind: "pica.error.retired_action", version: 1, data: { action, reason: "subscriptions_and_tiers_retired", pricing_url: "https://withpica.com/pricing", settings_url: "https://withpica.com/settings?tab=subscription", }, }, isError: true, }; } } //# sourceMappingURL=subscription.js.map