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