@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
196 lines • 10.1 kB
TypeScript
/**
* 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, PicaClient, type BillingTier, type HeldSummary, type PayOffer, type PricingSummary, type SubscriptionStatusResponse } from "@withpica/mcp-sdk";
import { ToolDefinition, ToolExecutor, ToolResult } from "./index.js";
/**
* ⚠️ ADR-276 REMOVED THE TRIAL AND THE TIER LADDER. This tool described both to
* agents until 2026-08-21.
*
* `billing_state` is the STORED value and keeps its three legacy names because
* that is what `organisations.billing_state` holds — renaming it here would make
* the wire disagree with the database. `plan` is the same fact in the vocabulary
* that is actually true, and it is what an agent should say out loud:
*
* billing_state plan what it means under ADR-276
* ────────────── ─────────────── ──────────────────────────────────────────
* trial free_runway walked in, never charged. no clock, no card.
* active resident paying one flat fee per 30-day cycle
* hibernated paused a cycle lapsed; deep-processing is off
*
* `trial_days_remaining` and `trial_ends_at` are GONE. They were null for 100%
* of production organisations (`billing_trial_ends_at` is NULL on all 241), so
* removing them drops two fields that could only ever say "null" — the cheapest
* possible moment to take a concept off the wire. Codes for arguments, labels
* for prose (`.claude/rules/canonical-model.md`).
*/
interface SubscriptionStatusFlat {
billing_state: "trial" | "active" | "hibernated";
plan: "free_runway" | "resident" | "paused";
current_tier: BillingTier | null;
capacity_used: number;
capacity_limit: number | null;
capacity_pct: number;
recommended_tier: BillingTier | null;
summary: string;
/**
* The org's price card and what is being withheld. Both are OMITTED rather
* than set to null when the route has none — `null` on a flat card reads to
* an agent as "pica measured this and the answer is nothing", which for a
* price would mean free. Absence says "pica did not answer", which is true.
*/
pricing?: PricingSummary;
held?: HeldSummary;
}
/** The stored billing state in the vocabulary ADR-276 actually uses. */
export declare function planFor(billingState: "trial" | "active" | "hibernated"): "free_runway" | "resident" | "paused";
/**
* 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 declare function flattenStatus(status: SubscriptionStatusResponse): SubscriptionStatusFlat;
/**
* 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 declare function composePriceLine(pricing?: PricingSummary, held?: HeldSummary): string | 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 declare function composeSummary(fields: {
billing_state: "trial" | "active" | "hibernated";
capacity_used: number;
/** WS-B — appended as one further sentence when the route measured them. */
pricing?: PricingSummary;
held?: HeldSummary;
}): string;
/**
* 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 declare function payRefusal(err: unknown, offer: PayOffer, entityId?: string): ToolResult | null;
/**
* 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 declare function parsePayErrorBody(err: ApiError): Record<string, unknown>;
export declare class SubscriptionTools {
private pica;
constructor(pica: PicaClient);
getTools(): Array<{
definition: ToolDefinition;
executor: ToolExecutor;
}>;
/**
* `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.
*/
private pay;
private status;
private manage;
}
export {};
//# sourceMappingURL=subscription.d.ts.map