@withpica/mcp-server 2.95.3 → 2.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/README.intro.md +115 -0
- package/README.md +188 -204
- package/dist/lib/changelog.generated.d.ts +2 -2
- package/dist/lib/changelog.generated.d.ts.map +1 -1
- package/dist/lib/changelog.generated.js +2 -2
- package/dist/lib/changelog.generated.js.map +1 -1
- package/dist/prompts/creator-question-atlas.d.ts.map +1 -1
- package/dist/prompts/creator-question-atlas.js +11 -0
- package/dist/prompts/creator-question-atlas.js.map +1 -1
- package/dist/prompts/index.js +2 -2
- package/dist/resources/agent-guide.d.ts +50 -4
- package/dist/resources/agent-guide.d.ts.map +1 -1
- package/dist/resources/agent-guide.js +109 -18
- package/dist/resources/agent-guide.js.map +1 -1
- package/dist/resources/index.d.ts.map +1 -1
- package/dist/resources/index.js +81 -2
- package/dist/resources/index.js.map +1 -1
- package/dist/resources/llms-primer.d.ts +1 -1
- package/dist/resources/llms-primer.d.ts.map +1 -1
- package/dist/resources/llms-primer.js +1 -1
- package/dist/resources/required-schemas.generated.d.ts +3 -12
- package/dist/resources/required-schemas.generated.d.ts.map +1 -1
- package/dist/resources/required-schemas.generated.js +3 -18
- package/dist/resources/required-schemas.generated.js.map +1 -1
- package/dist/resources/required-schemas.source.d.ts.map +1 -1
- package/dist/resources/required-schemas.source.js +13 -15
- package/dist/resources/required-schemas.source.js.map +1 -1
- package/dist/server-instructions.d.ts +2 -2
- package/dist/server-instructions.d.ts.map +1 -1
- package/dist/server-instructions.js +3 -3
- package/dist/skills/skills.generated.d.ts.map +1 -1
- package/dist/skills/skills.generated.js +18 -9
- package/dist/skills/skills.generated.js.map +1 -1
- package/dist/tools/agent-identity.d.ts.map +1 -1
- package/dist/tools/agent-identity.js +2 -1
- package/dist/tools/agent-identity.js.map +1 -1
- package/dist/tools/agreement-types.d.ts.map +1 -1
- package/dist/tools/agreement-types.js +23 -11
- package/dist/tools/agreement-types.js.map +1 -1
- package/dist/tools/agreements.d.ts.map +1 -1
- package/dist/tools/agreements.js +17 -9
- package/dist/tools/agreements.js.map +1 -1
- package/dist/tools/analytics.d.ts.map +1 -1
- package/dist/tools/analytics.js +6 -3
- package/dist/tools/analytics.js.map +1 -1
- package/dist/tools/assets.d.ts.map +1 -1
- package/dist/tools/assets.js +20 -13
- package/dist/tools/assets.js.map +1 -1
- package/dist/tools/audio-files.d.ts.map +1 -1
- package/dist/tools/audio-files.js +5 -3
- package/dist/tools/audio-files.js.map +1 -1
- package/dist/tools/bulk.d.ts.map +1 -1
- package/dist/tools/bulk.js +15 -9
- package/dist/tools/bulk.js.map +1 -1
- package/dist/tools/collaborators.d.ts.map +1 -1
- package/dist/tools/collaborators.js +41 -16
- package/dist/tools/collaborators.js.map +1 -1
- package/dist/tools/comparisons.d.ts.map +1 -1
- package/dist/tools/comparisons.js +2 -1
- package/dist/tools/comparisons.js.map +1 -1
- package/dist/tools/credits.d.ts +4 -3
- package/dist/tools/credits.d.ts.map +1 -1
- package/dist/tools/credits.js +16 -88
- package/dist/tools/credits.js.map +1 -1
- package/dist/tools/custody.d.ts.map +1 -1
- package/dist/tools/custody.js +43 -10
- package/dist/tools/custody.js.map +1 -1
- package/dist/tools/dashboard.d.ts.map +1 -1
- package/dist/tools/dashboard.js +24 -20
- package/dist/tools/dashboard.js.map +1 -1
- package/dist/tools/deprecations.d.ts +14 -7
- package/dist/tools/deprecations.d.ts.map +1 -1
- package/dist/tools/deprecations.js +24 -17
- package/dist/tools/deprecations.js.map +1 -1
- package/dist/tools/directory.d.ts.map +1 -1
- package/dist/tools/directory.js +7 -4
- package/dist/tools/directory.js.map +1 -1
- package/dist/tools/discovery.d.ts +120 -11
- package/dist/tools/discovery.d.ts.map +1 -1
- package/dist/tools/discovery.js +414 -38
- package/dist/tools/discovery.js.map +1 -1
- package/dist/tools/disputes.d.ts.map +1 -1
- package/dist/tools/disputes.js +3 -0
- package/dist/tools/disputes.js.map +1 -1
- package/dist/tools/documents.d.ts.map +1 -1
- package/dist/tools/documents.js +9 -6
- package/dist/tools/documents.js.map +1 -1
- package/dist/tools/enrichment.d.ts.map +1 -1
- package/dist/tools/enrichment.js +92 -14
- package/dist/tools/enrichment.js.map +1 -1
- package/dist/tools/error-envelope.d.ts +44 -0
- package/dist/tools/error-envelope.d.ts.map +1 -0
- package/dist/tools/error-envelope.js +17 -0
- package/dist/tools/error-envelope.js.map +1 -0
- package/dist/tools/exports.d.ts.map +1 -1
- package/dist/tools/exports.js +24 -9
- package/dist/tools/exports.js.map +1 -1
- package/dist/tools/groups.d.ts.map +1 -1
- package/dist/tools/groups.js +1 -0
- package/dist/tools/groups.js.map +1 -1
- package/dist/tools/import-documents.d.ts.map +1 -1
- package/dist/tools/import-documents.js +3 -2
- package/dist/tools/import-documents.js.map +1 -1
- package/dist/tools/import.d.ts.map +1 -1
- package/dist/tools/import.js +71 -38
- package/dist/tools/import.js.map +1 -1
- package/dist/tools/index.d.ts +71 -4
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +160 -41
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/licensing.d.ts.map +1 -1
- package/dist/tools/licensing.js +6 -2
- package/dist/tools/licensing.js.map +1 -1
- package/dist/tools/memory.d.ts.map +1 -1
- package/dist/tools/memory.js +8 -4
- package/dist/tools/memory.js.map +1 -1
- package/dist/tools/metadata.d.ts +7 -1
- package/dist/tools/metadata.d.ts.map +1 -1
- package/dist/tools/metadata.js +61 -12
- package/dist/tools/metadata.js.map +1 -1
- package/dist/tools/multimedia.d.ts.map +1 -1
- package/dist/tools/multimedia.js +7 -3
- package/dist/tools/multimedia.js.map +1 -1
- package/dist/tools/notes.d.ts.map +1 -1
- package/dist/tools/notes.js +9 -6
- package/dist/tools/notes.js.map +1 -1
- package/dist/tools/notifications.d.ts.map +1 -1
- package/dist/tools/notifications.js +16 -27
- package/dist/tools/notifications.js.map +1 -1
- package/dist/tools/people.d.ts.map +1 -1
- package/dist/tools/people.js +8 -5
- package/dist/tools/people.js.map +1 -1
- package/dist/tools/projects.d.ts.map +1 -1
- package/dist/tools/projects.js +16 -12
- package/dist/tools/projects.js.map +1 -1
- package/dist/tools/recording-custody.d.ts.map +1 -1
- package/dist/tools/recording-custody.js +9 -5
- package/dist/tools/recording-custody.js.map +1 -1
- package/dist/tools/recording-samples.d.ts.map +1 -1
- package/dist/tools/recording-samples.js +25 -6
- package/dist/tools/recording-samples.js.map +1 -1
- package/dist/tools/recordings.d.ts +10 -0
- package/dist/tools/recordings.d.ts.map +1 -1
- package/dist/tools/recordings.js +32 -19
- package/dist/tools/recordings.js.map +1 -1
- package/dist/tools/recovery-hints.d.ts.map +1 -1
- package/dist/tools/recovery-hints.js +58 -18
- package/dist/tools/recovery-hints.js.map +1 -1
- package/dist/tools/release-rich.js +5 -5
- package/dist/tools/release-rich.js.map +1 -1
- package/dist/tools/releases.d.ts.map +1 -1
- package/dist/tools/releases.js +21 -12
- package/dist/tools/releases.js.map +1 -1
- package/dist/tools/royalties.d.ts.map +1 -1
- package/dist/tools/royalties.js +12 -6
- package/dist/tools/royalties.js.map +1 -1
- package/dist/tools/send.d.ts.map +1 -1
- package/dist/tools/send.js +7 -4
- package/dist/tools/send.js.map +1 -1
- package/dist/tools/sessions.d.ts.map +1 -1
- package/dist/tools/sessions.js +8 -4
- package/dist/tools/sessions.js.map +1 -1
- package/dist/tools/settings.d.ts.map +1 -1
- package/dist/tools/settings.js +13 -6
- package/dist/tools/settings.js.map +1 -1
- package/dist/tools/share-links.d.ts.map +1 -1
- package/dist/tools/share-links.js +7 -3
- package/dist/tools/share-links.js.map +1 -1
- package/dist/tools/share-send.d.ts.map +1 -1
- package/dist/tools/share-send.js +15 -5
- package/dist/tools/share-send.js.map +1 -1
- package/dist/tools/shows.d.ts +31 -0
- package/dist/tools/shows.d.ts.map +1 -0
- package/dist/tools/shows.js +307 -0
- package/dist/tools/shows.js.map +1 -0
- package/dist/tools/skills.d.ts.map +1 -1
- package/dist/tools/skills.js +7 -0
- package/dist/tools/skills.js.map +1 -1
- package/dist/tools/split-sheets.d.ts.map +1 -1
- package/dist/tools/split-sheets.js +19 -12
- package/dist/tools/split-sheets.js.map +1 -1
- package/dist/tools/statements.js +2 -2
- package/dist/tools/statements.js.map +1 -1
- package/dist/tools/storage-config.d.ts.map +1 -1
- package/dist/tools/storage-config.js +5 -3
- package/dist/tools/storage-config.js.map +1 -1
- package/dist/tools/subscription.d.ts +162 -26
- package/dist/tools/subscription.d.ts.map +1 -1
- package/dist/tools/subscription.js +479 -241
- package/dist/tools/subscription.js.map +1 -1
- package/dist/tools/sync-placements.d.ts.map +1 -1
- package/dist/tools/sync-placements.js +10 -5
- package/dist/tools/sync-placements.js.map +1 -1
- package/dist/tools/telegram.d.ts.map +1 -1
- package/dist/tools/telegram.js +16 -24
- package/dist/tools/telegram.js.map +1 -1
- package/dist/tools/users.d.ts.map +1 -1
- package/dist/tools/users.js +12 -6
- package/dist/tools/users.js.map +1 -1
- package/dist/tools/works.d.ts.map +1 -1
- package/dist/tools/works.js +13 -10
- package/dist/tools/works.js.map +1 -1
- package/package.json +8 -4
- package/server.json +2 -2
- package/.claude/settings.local.json +0 -5
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
|
|
2
2
|
/* eslint-disable @typescript-eslint/no-explicit-any -- ToolExecutor signature uses Record<string, any> */
|
|
3
3
|
/**
|
|
4
|
-
* Subscription
|
|
4
|
+
* Subscription + billing tools — ADR-210 Phase 2, extended by the MPP pay
|
|
5
|
+
* rail (spec 2026-08-24 WS-B).
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
+
* Three MCP tools that surface the explicit billing read + action layer
|
|
7
8
|
* complementing the ambient `_meta.session_state.billing_slice` shipped
|
|
8
9
|
* in Phase 1:
|
|
9
10
|
*
|
|
10
11
|
* - pica_subscription_status (read; readOnlyHint=true)
|
|
11
|
-
* Wraps `GET /api/admin/subscription
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Wraps `GET /api/admin/subscription` and flattens it into the card
|
|
13
|
+
* below (`SubscriptionStatusFlat`), composing a templated `summary`
|
|
14
|
+
* line the agent uses verbatim. It is no longer the 9 fields ADR-210
|
|
15
|
+
* § Phase 2 specified: two trial fields came off on 2026-08-21, and
|
|
16
|
+
* WS-B added `pricing` + `held`, each present only when the route
|
|
17
|
+
* measured it. Read the interface, not a count.
|
|
14
18
|
*
|
|
15
19
|
* - pica_subscription_manage (action; readOnlyHint=false)
|
|
16
20
|
* Single-verb dispatcher over the existing
|
|
@@ -21,10 +25,20 @@
|
|
|
21
25
|
* advertises `capabilities.elicitation.url` per ADR-200 Phase 1
|
|
22
26
|
* (capability-gated; `feedback_mcp_sdk_capability_gates_elicitation.md`).
|
|
23
27
|
*
|
|
24
|
-
*
|
|
28
|
+
* - pica_billing_pay (read; readOnlyHint=true)
|
|
29
|
+
* Wraps `POST /api/admin/billing/pay-link`. Mints a link for one
|
|
30
|
+
* offer — resident_month / unlock / settle — and never charges: an
|
|
31
|
+
* agent POSTs the link with a Machine Payments credential, or a
|
|
32
|
+
* person opens it and pays by card. The refusals the ROUTE itself
|
|
33
|
+
* composes — billing disabled, nothing held, offer moved — come back
|
|
34
|
+
* as structured results rather than thrown ApiErrors. A 4xx from the
|
|
35
|
+
* auth wrapper in front of it is quoted, never re-interpreted; a 4xx
|
|
36
|
+
* carrying nothing quotable, and everything from 500 up, is re-thrown
|
|
37
|
+
* (`payRefusal` below is the single place that decides which).
|
|
38
|
+
*
|
|
39
|
+
* No new Stripe SDK calls — every route here is reused as-is.
|
|
25
40
|
*/
|
|
26
|
-
import {
|
|
27
|
-
import { clientSupportsUrlElicitation } from "@withpica/mcp-utils";
|
|
41
|
+
import { ApiError, } from "@withpica/mcp-sdk";
|
|
28
42
|
// ADR-205 D7: enterprise is sales-led, not bookable through Checkout.
|
|
29
43
|
// Mirror the route-layer allowlist here for a better tool-side error
|
|
30
44
|
// message before the request leaves the agent.
|
|
@@ -36,19 +50,78 @@ const VALID_PAID_TIERS = [
|
|
|
36
50
|
];
|
|
37
51
|
const VALID_ACTIONS = ["subscribe", "upgrade", "manage", "cancel"];
|
|
38
52
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
* The offers `POST /api/admin/billing/pay-link` accepts. Checked here as well
|
|
54
|
+
* as at the route because an unknown offer is a mistake the agent can fix
|
|
55
|
+
* from the tool's own answer, without spending a round trip on a 400.
|
|
56
|
+
*/
|
|
57
|
+
const VALID_PAY_OFFERS = [
|
|
58
|
+
"resident_month",
|
|
59
|
+
"unlock",
|
|
60
|
+
"settle",
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* Where a refused mint sends the agent next, by the route's error code.
|
|
64
|
+
* Mirrored in `RECOVERY_HINTS.pica_billing_pay` (recovery-hints.ts), which is
|
|
65
|
+
* what `pica_tool_details` reads; this copy is what the refusal itself
|
|
66
|
+
* carries, so an agent that never opens tool details still gets the pointer.
|
|
67
|
+
*/
|
|
68
|
+
const NEXT_TOOL_FOR_PAY_ERROR = {
|
|
69
|
+
nothing_held_for_entity: "pica_dashboard_briefing",
|
|
70
|
+
offer_no_longer_applies: "pica_subscription_status",
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The pay-link route's own flag-off body, verbatim
|
|
74
|
+
* (`app/api/admin/billing/pay-link/route.ts`: `{ error: "billing not enabled" }`).
|
|
75
|
+
*
|
|
76
|
+
* Named rather than inlined because it is the ONE 403 that licenses a
|
|
77
|
+
* statement about money, and a reader needs to be able to grep both ends of
|
|
78
|
+
* that coupling. If the route ever reworded it, this comparison stops
|
|
79
|
+
* matching and every 403 falls through to the access-refusal branch below —
|
|
80
|
+
* which says less, but says nothing false.
|
|
81
|
+
*/
|
|
82
|
+
const BILLING_DISABLED_ERROR = "billing not enabled";
|
|
83
|
+
function isRecord(value) {
|
|
84
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
/** The first candidate that is a non-blank string. */
|
|
87
|
+
function firstString(...candidates) {
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
/** The stored billing state in the vocabulary ADR-276 actually uses. */
|
|
96
|
+
export function planFor(billingState) {
|
|
97
|
+
if (billingState === "active")
|
|
98
|
+
return "resident";
|
|
99
|
+
if (billingState === "hibernated")
|
|
100
|
+
return "paused";
|
|
101
|
+
return "free_runway";
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Compose `SubscriptionStatusFlat` from the GET admin route's shape: eight
|
|
105
|
+
* fields that are always present (`billing_state`, `plan`, `current_tier`,
|
|
106
|
+
* `capacity_used`, `capacity_limit`, `capacity_pct`, `recommended_tier`,
|
|
107
|
+
* `summary`) plus `pricing` and `held`, each emitted ONLY when the route
|
|
108
|
+
* measured it.
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ This opened by quoting a FIELD COUNT until 2026-08-26, and that count had
|
|
111
|
+
* been wrong since 08-21 — `trial_days_remaining` and `trial_ends_at` came off
|
|
112
|
+
* then, and WS-B added the two optional keys. A count is the one thing a
|
|
113
|
+
* docstring cannot keep true across a field change, and it rots silently
|
|
114
|
+
* because nothing compiles against it. Do not reintroduce one: name the fields
|
|
115
|
+
* (above) or point at the interface, which the compiler keeps honest.
|
|
116
|
+
*
|
|
117
|
+
* Mirrors `getOrgBillingSlice`'s "no meaningful cap" rule for `capacity_pct`
|
|
118
|
+
* (0 whenever current_tier is null OR enterprise) and carries through
|
|
119
|
+
* `overflow.recommendedTier` (already `inferTierFromCount`-derived) per
|
|
120
|
+
* Stage 1 Q2.
|
|
44
121
|
*
|
|
45
122
|
* Exported for unit tests.
|
|
46
123
|
*/
|
|
47
124
|
export function flattenStatus(status) {
|
|
48
|
-
const trialEndsAt = status.trialEndsAt;
|
|
49
|
-
const trialDaysRemaining = status.billingState === "trial" && trialEndsAt
|
|
50
|
-
? Math.max(0, Math.ceil((new Date(trialEndsAt).getTime() - Date.now()) / 86_400_000))
|
|
51
|
-
: null;
|
|
52
125
|
const capacityUsed = status.overflow?.currentCount ?? 0;
|
|
53
126
|
const capacityLimit = status.overflow?.capacity ?? null;
|
|
54
127
|
const recommendedTier = status.overflow?.recommendedTier ?? null;
|
|
@@ -63,10 +136,14 @@ export function flattenStatus(status) {
|
|
|
63
136
|
const capacityPct = hasMeaningfulCap
|
|
64
137
|
? Math.round((capacityUsed / capacityLimit) * 100)
|
|
65
138
|
: 0;
|
|
139
|
+
// WS-B: both arrive `null` when billing enforcement is off, and are absent
|
|
140
|
+
// entirely against a deployment whose route predates them. Either way there
|
|
141
|
+
// is nothing to say, so neither key is emitted.
|
|
142
|
+
const pricing = status.pricing ?? undefined;
|
|
143
|
+
const held = status.held ?? undefined;
|
|
66
144
|
return {
|
|
67
145
|
billing_state: status.billingState,
|
|
68
|
-
|
|
69
|
-
trial_ends_at: trialEndsAt,
|
|
146
|
+
plan: planFor(status.billingState),
|
|
70
147
|
current_tier: status.tier,
|
|
71
148
|
capacity_used: capacityUsed,
|
|
72
149
|
capacity_limit: capacityLimit,
|
|
@@ -74,57 +151,232 @@ export function flattenStatus(status) {
|
|
|
74
151
|
recommended_tier: recommendedTier,
|
|
75
152
|
summary: composeSummary({
|
|
76
153
|
billing_state: status.billingState,
|
|
77
|
-
trial_days_remaining: trialDaysRemaining,
|
|
78
|
-
current_tier: status.tier,
|
|
79
154
|
capacity_used: capacityUsed,
|
|
80
|
-
|
|
81
|
-
|
|
155
|
+
pricing,
|
|
156
|
+
held,
|
|
82
157
|
}),
|
|
158
|
+
...(pricing ? { pricing } : {}),
|
|
159
|
+
...(held ? { held } : {}),
|
|
83
160
|
};
|
|
84
161
|
}
|
|
162
|
+
/** `n` with the noun pluralised — "1 song", "7 songs". */
|
|
163
|
+
function plural(n, noun) {
|
|
164
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
165
|
+
}
|
|
85
166
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
167
|
+
* The price half of the summary, built ENTIRELY from the figures the route
|
|
168
|
+
* sent. No amount of money appears here on purpose: the money is worded once,
|
|
169
|
+
* by the pay-link route's offer labels (`formatMoney` over the org's own
|
|
170
|
+
* currency, whose minor-unit conventions differ from ICU's for four of the
|
|
171
|
+
* ten supported currencies). A second wording here would be a second home for
|
|
172
|
+
* the price — the defect FIX_LOG 2026-08-20 records as having had ten.
|
|
173
|
+
*
|
|
174
|
+
* Returns `null` when there is nothing measured to say, so the caller appends
|
|
175
|
+
* nothing rather than a sentence about absence.
|
|
176
|
+
*
|
|
177
|
+
* Exported for unit tests.
|
|
178
|
+
*/
|
|
179
|
+
export function composePriceLine(pricing, held) {
|
|
180
|
+
const clauses = [];
|
|
181
|
+
if (pricing) {
|
|
182
|
+
clauses.push(`Deep processing is free for your first ` +
|
|
183
|
+
`${plural(pricing.freeRunwayDeepProcesses, "song")}, and a resident ` +
|
|
184
|
+
`month includes ${plural(pricing.residentIncludedDeepProcesses, "song")}.`);
|
|
185
|
+
}
|
|
186
|
+
// count === 0 is the common case and says nothing worth a sentence; saying
|
|
187
|
+
// "0 findings are held" invites an agent to offer a pay link nobody needs.
|
|
188
|
+
if (held && held.count > 0) {
|
|
189
|
+
clauses.push(`${plural(held.count, "finding")} across ` +
|
|
190
|
+
`${plural(held.entities, "work")} are held until one of the offers ` +
|
|
191
|
+
`is paid — ask for a pay link to see the price and how to pay it.`);
|
|
192
|
+
}
|
|
193
|
+
return clauses.length > 0 ? clauses.join(" ") : null;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The sentence an agent says out loud. Server-side template (Stage 1 Q1 option
|
|
197
|
+
* A) so wording does not vary across connectors.
|
|
198
|
+
*
|
|
199
|
+
* ⚠️ REWRITTEN 2026-08-21. Every branch was false under ADR-276:
|
|
200
|
+
*
|
|
201
|
+
* trial → "You're on a 9-day window of your trial. Today's catalogue
|
|
202
|
+
* fits the solo tier." — there is no trial and no tier ladder
|
|
203
|
+
* hibernated → "Your trial has ended and the catalogue is read-only.
|
|
204
|
+
* Subscribe to restore writes."
|
|
205
|
+
* — hibernation gates `pica_audio_analyze` and nothing else;
|
|
206
|
+
* the catalogue is NOT read-only, and there is nothing to
|
|
207
|
+
* subscribe to
|
|
208
|
+
* active → "Currently on pro. 412 of 4,000 slots used."
|
|
209
|
+
* — the capacity ladder was superseded
|
|
210
|
+
*
|
|
211
|
+
* 239 of 241 production organisations sit at `billing_state = 'trial'`, so the
|
|
212
|
+
* first branch was what an agent said to almost every user pica has. The screen
|
|
213
|
+
* had already been corrected; this had not, which is the recurring shape here —
|
|
214
|
+
* see `.claude/rules/canonical-model.md` on `WORK_NO_WRITER`.
|
|
215
|
+
*
|
|
216
|
+
* Capacity is deliberately not mentioned: no cap is enforced under ADR-276, and
|
|
217
|
+
* quoting a limit implies one. The work count is stated as a plain fact instead.
|
|
89
218
|
*
|
|
90
219
|
* Exported for unit tests.
|
|
91
220
|
*/
|
|
92
221
|
export function composeSummary(fields) {
|
|
93
|
-
const { billing_state,
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
222
|
+
const { billing_state, capacity_used } = fields;
|
|
223
|
+
const works = `${capacity_used} active work${capacity_used === 1 ? "" : "s"}`;
|
|
224
|
+
const priceLine = composePriceLine(fields.pricing, fields.held);
|
|
225
|
+
const withPrice = (base) => priceLine ? `${base} ${priceLine}` : base;
|
|
226
|
+
if (billing_state === "hibernated") {
|
|
227
|
+
return withPrice(`Deep-processing is paused on this catalogue because a cycle went unpaid. ` +
|
|
228
|
+
`Nothing has been deleted and you can still read and export everything ` +
|
|
229
|
+
`(${works} held). Becoming a resident again switches deep-processing back on.`);
|
|
230
|
+
}
|
|
231
|
+
if (billing_state === "active") {
|
|
232
|
+
return withPrice(`You're a resident — one flat fee per 30-day cycle, nothing auto-renews. ${works}.`);
|
|
233
|
+
}
|
|
234
|
+
// trial === the free runway. No clock, no card, no countdown.
|
|
235
|
+
return withPrice(`You're on the free runway: no card, no countdown, and you are not charged ` +
|
|
236
|
+
`for holding a catalogue. ${works} so far.`);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Turn a pay-link 4xx into the structured refusal an agent can act on, or
|
|
240
|
+
* `null` when the caller must keep propagating it — 5xx, timeouts, network
|
|
241
|
+
* failures, and any 403 whose body carries no message worth quoting.
|
|
242
|
+
*
|
|
243
|
+
* The 403 branch is the delicate one: exactly one 403 on this path is about
|
|
244
|
+
* money, and the rest are about access. See its comment.
|
|
245
|
+
*
|
|
246
|
+
* Exported for unit tests.
|
|
247
|
+
*/
|
|
248
|
+
export function payRefusal(err, offer, entityId) {
|
|
249
|
+
if (!(err instanceof ApiError))
|
|
250
|
+
return null;
|
|
251
|
+
if (err.status !== 403 && err.status !== 400 && err.status !== 409) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const body = parsePayErrorBody(err);
|
|
255
|
+
const code = typeof body.error === "string" ? body.error : undefined;
|
|
256
|
+
if (err.status === 403) {
|
|
257
|
+
// ⚠️ ONLY the route's own flag-off body may license a claim about money.
|
|
258
|
+
//
|
|
259
|
+
// This branch answered every 403 with "nothing is owed", and the route is
|
|
260
|
+
// not the only thing on this path that answers 403. It sits behind
|
|
261
|
+
// `withDualAuth`, whose scope denial is
|
|
262
|
+
// `{ error: { code: "INSUFFICIENT_SCOPE", message, remedy } }` — an
|
|
263
|
+
// OBJECT, so the string `code` above is undefined — and the
|
|
264
|
+
// team-permission guard answers `{ error: "<message>", code: "TEAM_..." }`.
|
|
265
|
+
// Both mean "this connection may not ask", which is not a statement about
|
|
266
|
+
// a bill, and the agent was relaying it as one.
|
|
267
|
+
if (code === BILLING_DISABLED_ERROR) {
|
|
268
|
+
return {
|
|
269
|
+
content: [
|
|
270
|
+
{
|
|
271
|
+
type: "text",
|
|
272
|
+
text: "pica is not charging on this deployment, so there is no pay link " +
|
|
273
|
+
"to mint and nothing is owed. Everything the catalogue does is " +
|
|
274
|
+
"available; no offer applies.",
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
structuredContent: {
|
|
278
|
+
kind: "pica.error.billing_not_enabled",
|
|
279
|
+
version: 1,
|
|
280
|
+
data: { offer, reason: BILLING_DISABLED_ERROR },
|
|
281
|
+
},
|
|
282
|
+
isError: true,
|
|
283
|
+
};
|
|
108
284
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
285
|
+
// Any other 403 is an ACCESS refusal. Quote the refuser's own words and
|
|
286
|
+
// make no claim about what is or is not owed — the call never reached the
|
|
287
|
+
// billing engine, so nothing here knows.
|
|
288
|
+
const nested = isRecord(body.error) ? body.error : undefined;
|
|
289
|
+
const message = firstString(nested?.message, code);
|
|
290
|
+
// No quotable message means no honest account of WHY. Returning null hands
|
|
291
|
+
// the error back to the executor to re-throw, exactly as a 500 is handled:
|
|
292
|
+
// an opaque failure is better information than a confident wrong sentence.
|
|
293
|
+
if (!message)
|
|
294
|
+
return null;
|
|
295
|
+
const remedy = firstString(nested?.remedy, body.remedy);
|
|
296
|
+
return {
|
|
297
|
+
content: [
|
|
298
|
+
{
|
|
299
|
+
type: "text",
|
|
300
|
+
text: `pica refused to mint a pay link: ${message}. That is an access ` +
|
|
301
|
+
`refusal and says nothing about what is or is not owed.` +
|
|
302
|
+
(remedy ? ` ${remedy}` : ""),
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
structuredContent: {
|
|
306
|
+
kind: "pica.error.forbidden",
|
|
307
|
+
version: 1,
|
|
308
|
+
data: {
|
|
309
|
+
offer,
|
|
310
|
+
...(entityId ? { entity_id: entityId } : {}),
|
|
311
|
+
error: firstString(nested?.code, body.code) ?? "forbidden",
|
|
312
|
+
message,
|
|
313
|
+
...(remedy ? { next_step: remedy } : {}),
|
|
314
|
+
retry_safe: false,
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
isError: true,
|
|
318
|
+
};
|
|
113
319
|
}
|
|
114
|
-
|
|
115
|
-
|
|
320
|
+
const offers = Array.isArray(body.offers)
|
|
321
|
+
? body.offers
|
|
322
|
+
: undefined;
|
|
323
|
+
let text;
|
|
324
|
+
if (code === "nothing_held_for_entity") {
|
|
325
|
+
text =
|
|
326
|
+
"nothing is held on that work, so there is nothing to unlock. Check " +
|
|
327
|
+
"what is actually waiting across the catalogue before offering to pay.";
|
|
116
328
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
329
|
+
else if (code === "offer_no_longer_applies") {
|
|
330
|
+
const list = offers?.map((o) => o.label).join("; ");
|
|
331
|
+
text = list
|
|
332
|
+
? `that offer no longer applies. What is on the table now: ${list}.`
|
|
333
|
+
: "that offer no longer applies, and no other offer applies either.";
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
text = `that pay link could not be minted: ${code ?? err.message}.`;
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
content: [{ type: "text", text }],
|
|
340
|
+
structuredContent: {
|
|
341
|
+
kind: "pica.error.offer_unavailable",
|
|
342
|
+
version: 1,
|
|
343
|
+
data: {
|
|
344
|
+
offer,
|
|
345
|
+
...(entityId ? { entity_id: entityId } : {}),
|
|
346
|
+
error: code ?? "pay_link_refused",
|
|
347
|
+
...(offers ? { offers } : {}),
|
|
348
|
+
next_tool: (code ? NEXT_TOOL_FOR_PAY_ERROR[code] : undefined) ??
|
|
349
|
+
"pica_subscription_status",
|
|
350
|
+
retry_safe: false,
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
isError: true,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Pull the route's JSON body back out of an `ApiError`. The SDK folds the
|
|
358
|
+
* response text into the message (`"API request failed: 400 <body>"`, and
|
|
359
|
+
* `"Authentication failed: 403 <body>"` on the auth path) rather than
|
|
360
|
+
* carrying a structured body — the same extraction `duplicates.ts` and
|
|
361
|
+
* `integrity.ts` do. Returns `{}` when the body is absent or not JSON, so
|
|
362
|
+
* every caller reads a `code` of `undefined` rather than throwing twice.
|
|
363
|
+
*
|
|
364
|
+
* Exported for unit tests.
|
|
365
|
+
*/
|
|
366
|
+
export function parsePayErrorBody(err) {
|
|
367
|
+
const match = err.message.match(/^(?:API request failed|Authentication failed):\s*\d{3}\s*([\s\S]*)$/);
|
|
368
|
+
const text = match?.[1]?.trim();
|
|
369
|
+
if (!text)
|
|
370
|
+
return {};
|
|
371
|
+
try {
|
|
372
|
+
const parsed = JSON.parse(text);
|
|
373
|
+
return parsed && typeof parsed === "object"
|
|
374
|
+
? parsed
|
|
375
|
+
: {};
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return {};
|
|
120
379
|
}
|
|
121
|
-
const limitClause = capacity_limit === null
|
|
122
|
-
? slotsClause
|
|
123
|
-
: `${capacity_used} of ${capacity_limit} slots used`;
|
|
124
|
-
const tierClause = current_tier
|
|
125
|
-
? `Currently on ${current_tier}.`
|
|
126
|
-
: "Tier unclassified.";
|
|
127
|
-
return `${tierClause} ${limitClause}.`;
|
|
128
380
|
}
|
|
129
381
|
export class SubscriptionTools {
|
|
130
382
|
pica;
|
|
@@ -139,15 +391,16 @@ export class SubscriptionTools {
|
|
|
139
391
|
scope: "admin",
|
|
140
392
|
audiences: ["stdio"],
|
|
141
393
|
tier: "read",
|
|
142
|
-
description: "Use when the user asks: 'what's my plan?', '
|
|
143
|
-
"Read the org's current billing posture.
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
394
|
+
description: "Use when the user asks: 'what's my plan?', 'am I paying for this?', " +
|
|
395
|
+
"'what does pica cost me?'. Read the org's current billing posture. " +
|
|
396
|
+
"Returns `plan` — free_runway (walked in, never charged, no clock), " +
|
|
397
|
+
"resident (paying one flat fee per 30-day cycle), or paused (a cycle " +
|
|
398
|
+
"lapsed, so deep-processing is off) — alongside the stored " +
|
|
399
|
+
"billing_state, the active work count, and a one-line summary to use " +
|
|
400
|
+
"verbatim. There is NO trial and NO tier ladder: pica is free to walk " +
|
|
401
|
+
"in and residency is opted into one cycle at a time, so never tell a " +
|
|
402
|
+
"user their trial is ending or offer to upgrade a tier. Complement to " +
|
|
403
|
+
"the ambient _meta.session_state.billing_slice; use this tool when an " +
|
|
151
404
|
"explicit answer is needed.",
|
|
152
405
|
workflows: "infrastructure",
|
|
153
406
|
vernacular_kind: "creator-entry",
|
|
@@ -171,15 +424,14 @@ export class SubscriptionTools {
|
|
|
171
424
|
scope: "admin",
|
|
172
425
|
audiences: ["stdio"],
|
|
173
426
|
tier: "write",
|
|
174
|
-
description: "
|
|
175
|
-
"
|
|
176
|
-
"
|
|
177
|
-
"
|
|
178
|
-
"
|
|
179
|
-
"
|
|
180
|
-
"
|
|
181
|
-
"
|
|
182
|
-
"pica_subscription_status so the agent can read recommended_tier first.",
|
|
427
|
+
description: "Retired — every action refuses. pica no longer sells a subscription " +
|
|
428
|
+
"or a capacity tier, so there is nothing here to subscribe to, " +
|
|
429
|
+
"upgrade, or cancel. pica is free to walk in and use; residency is a " +
|
|
430
|
+
"single one-off charge for a 30-day cycle that the user confirms " +
|
|
431
|
+
"themselves, and nothing auto-renews, so there is no recurring " +
|
|
432
|
+
"mandate to manage. Direct the user to the pricing page or their " +
|
|
433
|
+
"billing settings instead. Kept callable only so an agent that " +
|
|
434
|
+
"invokes it receives an explanation rather than an unknown-tool error.",
|
|
183
435
|
workflows: "infrastructure",
|
|
184
436
|
vernacular_kind: "specialist",
|
|
185
437
|
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
|
|
@@ -195,8 +447,9 @@ export class SubscriptionTools {
|
|
|
195
447
|
tier: {
|
|
196
448
|
type: "string",
|
|
197
449
|
enum: [...VALID_PAID_TIERS],
|
|
198
|
-
description: "
|
|
199
|
-
"
|
|
450
|
+
description: "Ignored. pica no longer sells capacity tiers; the enum is " +
|
|
451
|
+
"retained only so an existing caller's argument still " +
|
|
452
|
+
"validates rather than erroring on an unknown property.",
|
|
200
453
|
},
|
|
201
454
|
currency: {
|
|
202
455
|
type: "string",
|
|
@@ -216,8 +469,121 @@ export class SubscriptionTools {
|
|
|
216
469
|
},
|
|
217
470
|
executor: this.manage.bind(this),
|
|
218
471
|
},
|
|
472
|
+
{
|
|
473
|
+
definition: {
|
|
474
|
+
name: "pica_billing_pay",
|
|
475
|
+
// read:catalog, NOT write:finances — and deliberately so. Minting a
|
|
476
|
+
// link mints a capability, not a charge: nothing is billed until
|
|
477
|
+
// whoever holds the link pays it, so a member fetching a link for
|
|
478
|
+
// the owner to pay is the intended flow. The route gates the same
|
|
479
|
+
// way (app/api/admin/billing/pay-link/route.ts).
|
|
480
|
+
scope: "read:catalog",
|
|
481
|
+
audiences: ["stdio", "http"],
|
|
482
|
+
tier: "read",
|
|
483
|
+
description: "Use when the user asks: 'how do i pay pica?', 'what does pica cost?'. " +
|
|
484
|
+
"Also use when pica is holding findings, or a deep process was " +
|
|
485
|
+
"refused for runway. Mints a pay link for one offer: resident_month " +
|
|
486
|
+
"(one month of residency; shows every held finding within the " +
|
|
487
|
+
"allowance), unlock (one work's held findings, needs entity_id), or " +
|
|
488
|
+
"settle (a resident's tab). Never charges by itself: an agent POSTs " +
|
|
489
|
+
"the link with a Machine Payments credential (Link agent wallet or " +
|
|
490
|
+
"Tempo), or a person opens it in a browser and pays by card. Returns " +
|
|
491
|
+
"the offers with amounts derived from pica's constants and the " +
|
|
492
|
+
"current held count. Refuses when billing enforcement is off.",
|
|
493
|
+
workflows: "infrastructure",
|
|
494
|
+
vernacular_kind: "creator-entry",
|
|
495
|
+
inputSchema: {
|
|
496
|
+
type: "object",
|
|
497
|
+
properties: {
|
|
498
|
+
offer: {
|
|
499
|
+
type: "string",
|
|
500
|
+
enum: [...VALID_PAY_OFFERS],
|
|
501
|
+
description: "resident_month (a 30-day cycle), unlock (one work's held " +
|
|
502
|
+
"findings), or settle (what a resident already owes).",
|
|
503
|
+
},
|
|
504
|
+
entity_id: {
|
|
505
|
+
type: "string",
|
|
506
|
+
description: "The work to unlock (offer = unlock).",
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
required: ["offer"],
|
|
510
|
+
additionalProperties: false,
|
|
511
|
+
},
|
|
512
|
+
annotations: {
|
|
513
|
+
// readOnly is honest here: this mints a link and changes nothing
|
|
514
|
+
// an org can see. idempotentHint is false because each mint is a
|
|
515
|
+
// fresh token with a fresh expiry, not the same link returned twice.
|
|
516
|
+
readOnlyHint: true,
|
|
517
|
+
destructiveHint: false,
|
|
518
|
+
idempotentHint: false,
|
|
519
|
+
openWorldHint: false,
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
executor: this.pay.bind(this),
|
|
523
|
+
},
|
|
219
524
|
];
|
|
220
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* `pica_billing_pay` — hand the caller a link, and never a charge.
|
|
528
|
+
*
|
|
529
|
+
* The refusals the ROUTE composes (the flag is off; nothing is held on that
|
|
530
|
+
* work; that offer no longer applies) become structured results rather than
|
|
531
|
+
* thrown ApiErrors — a throw reaches the agent as UNKNOWN_ERROR, which
|
|
532
|
+
* reads as "pica is broken" when the honest answer is "there is nothing to
|
|
533
|
+
* pay for". `payRefusal` decides; everything it declines (5xx, timeouts,
|
|
534
|
+
* network failures, and a 403 with nothing quotable in it) is re-thrown
|
|
535
|
+
* unchanged, because an outage dressed as a tidy refusal is the worse lie.
|
|
536
|
+
*/
|
|
537
|
+
async pay(args) {
|
|
538
|
+
const offer = args.offer;
|
|
539
|
+
if (!offer || !VALID_PAY_OFFERS.includes(offer)) {
|
|
540
|
+
return {
|
|
541
|
+
content: [
|
|
542
|
+
{
|
|
543
|
+
type: "text",
|
|
544
|
+
text: `Error: offer must be one of ${VALID_PAY_OFFERS.join(", ")}.`,
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
structuredContent: {
|
|
548
|
+
kind: "pica.error.invalid_argument",
|
|
549
|
+
version: 1,
|
|
550
|
+
data: { argument: "offer", valid: [...VALID_PAY_OFFERS] },
|
|
551
|
+
},
|
|
552
|
+
isError: true,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
const entityId = typeof args.entity_id === "string" ? args.entity_id : undefined;
|
|
556
|
+
let link;
|
|
557
|
+
try {
|
|
558
|
+
link = await this.pica.billing.mintPayLink({
|
|
559
|
+
offer,
|
|
560
|
+
entity_id: entityId,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
const refusal = payRefusal(err, offer, entityId);
|
|
565
|
+
if (refusal)
|
|
566
|
+
return refusal;
|
|
567
|
+
throw err;
|
|
568
|
+
}
|
|
569
|
+
// The label is the ONE place the amount is worded — composed server-side
|
|
570
|
+
// in the org's own currency. Quote it; never re-format `amount_minor`.
|
|
571
|
+
const label = link.offers?.find((o) => o.offer === link.offer)?.label ??
|
|
572
|
+
link.offer;
|
|
573
|
+
return {
|
|
574
|
+
content: [
|
|
575
|
+
{
|
|
576
|
+
type: "text",
|
|
577
|
+
text: `${label}: ${link.pay_url} (expires ${link.expires_at}). ${link.how}`,
|
|
578
|
+
},
|
|
579
|
+
],
|
|
580
|
+
structuredContent: {
|
|
581
|
+
kind: "pica.card.pay_link",
|
|
582
|
+
version: 1,
|
|
583
|
+
data: link,
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
}
|
|
221
587
|
async status(_args) {
|
|
222
588
|
const status = await this.pica.subscription.getStatus();
|
|
223
589
|
if (!status) {
|
|
@@ -247,7 +613,7 @@ export class SubscriptionTools {
|
|
|
247
613
|
},
|
|
248
614
|
};
|
|
249
615
|
}
|
|
250
|
-
async manage(args
|
|
616
|
+
async manage(args) {
|
|
251
617
|
const action = args.action;
|
|
252
618
|
if (!action || !VALID_ACTIONS.includes(action)) {
|
|
253
619
|
return {
|
|
@@ -268,175 +634,47 @@ export class SubscriptionTools {
|
|
|
268
634
|
isError: true,
|
|
269
635
|
};
|
|
270
636
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return {
|
|
300
|
-
content: [
|
|
301
|
-
{
|
|
302
|
-
type: "text",
|
|
303
|
-
text: `Error: tier must be one of ${VALID_PAID_TIERS.join(", ")}. ` +
|
|
304
|
-
`Enterprise is sales-led — contact PICA directly.`,
|
|
305
|
-
},
|
|
306
|
-
],
|
|
307
|
-
structuredContent: {
|
|
308
|
-
kind: "pica.error.invalid_argument",
|
|
309
|
-
version: 1,
|
|
310
|
-
data: {
|
|
311
|
-
argument: "tier",
|
|
312
|
-
valid: [...VALID_PAID_TIERS],
|
|
313
|
-
},
|
|
314
|
-
},
|
|
315
|
-
isError: true,
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
try {
|
|
319
|
-
const result = await this.pica.subscription.mintCheckout({
|
|
320
|
-
tier,
|
|
321
|
-
currency: typeof args.currency === "string" ? args.currency : "gbp",
|
|
322
|
-
});
|
|
323
|
-
return this.buildLinkResult({
|
|
324
|
-
url: result.checkoutUrl,
|
|
325
|
-
expiresAt: result.expiresAt,
|
|
326
|
-
surface: "checkout",
|
|
327
|
-
name: `Subscribe to PICA (${tier})`,
|
|
328
|
-
description: `Open Stripe Checkout to subscribe to the ${tier} tier.`,
|
|
329
|
-
ctx,
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
catch (err) {
|
|
333
|
-
const message = err instanceof Error ? err.message : "checkout mint failed";
|
|
334
|
-
return {
|
|
335
|
-
content: [
|
|
336
|
-
{ type: "text", text: `Could not mint checkout: ${message}` },
|
|
337
|
-
],
|
|
338
|
-
structuredContent: {
|
|
339
|
-
kind: "pica.error.checkout_mint_failed",
|
|
340
|
-
version: 1,
|
|
341
|
-
data: { error: message },
|
|
342
|
-
},
|
|
343
|
-
isError: true,
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
// manage / cancel → portal
|
|
348
|
-
try {
|
|
349
|
-
const result = await this.pica.subscription.mintPortal();
|
|
350
|
-
return this.buildLinkResult({
|
|
351
|
-
url: result.portalUrl,
|
|
352
|
-
expiresAt: result.expiresAt,
|
|
353
|
-
surface: "portal",
|
|
354
|
-
name: action === "cancel" ? "Cancel subscription" : "Manage subscription",
|
|
355
|
-
description: action === "cancel"
|
|
356
|
-
? "Open the Stripe Billing Portal to cancel your subscription."
|
|
357
|
-
: "Open the Stripe Billing Portal to update payment, change tier, or cancel.",
|
|
358
|
-
ctx,
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
catch (err) {
|
|
362
|
-
const message = err instanceof Error ? err.message : "portal mint failed";
|
|
363
|
-
return {
|
|
364
|
-
content: [
|
|
365
|
-
{
|
|
366
|
-
type: "text",
|
|
367
|
-
text: `Could not open billing portal: ${message}`,
|
|
368
|
-
},
|
|
369
|
-
],
|
|
370
|
-
structuredContent: {
|
|
371
|
-
kind: "pica.error.portal_mint_failed",
|
|
372
|
-
version: 1,
|
|
373
|
-
data: {
|
|
374
|
-
error: message,
|
|
375
|
-
// The route returns "No Stripe customer found — subscribe first"
|
|
376
|
-
// when the org has no stripeCustomerId; surface a recovery hint
|
|
377
|
-
// so the agent can chain.
|
|
378
|
-
next_tool: message.toLowerCase().includes("subscribe first") ||
|
|
379
|
-
message.toLowerCase().includes("no stripe customer")
|
|
380
|
-
? "pica_subscription_manage"
|
|
381
|
-
: undefined,
|
|
382
|
-
},
|
|
383
|
-
},
|
|
384
|
-
isError: true,
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
/**
|
|
389
|
-
* Universal-floor return per ADR-200 Phase 1: every successful manage
|
|
390
|
-
* response carries a `resource_link` so any compliant MCP client can
|
|
391
|
-
* render or open the URL. Clients that advertise
|
|
392
|
-
* `capabilities.elicitation.url` additionally receive a server-issued
|
|
393
|
-
* elicitation/create request — the gate is mandatory because
|
|
394
|
-
* `elicitInput` with `mode: "url"` throws synchronously against
|
|
395
|
-
* non-supporters (`feedback_mcp_sdk_capability_gates_elicitation.md`).
|
|
396
|
-
*/
|
|
397
|
-
async buildLinkResult(opts) {
|
|
398
|
-
const { url, expiresAt, surface, name, description, ctx } = opts;
|
|
399
|
-
const summary = surface === "checkout"
|
|
400
|
-
? `${name} ready. Open ${url} to complete checkout. Link expires ${expiresAt}.`
|
|
401
|
-
: `${name} ready. Open ${url} to manage your subscription. Link expires ${expiresAt}.`;
|
|
402
|
-
const content = [
|
|
403
|
-
{ type: "text", text: summary },
|
|
404
|
-
{
|
|
405
|
-
type: "resource_link",
|
|
406
|
-
uri: url,
|
|
407
|
-
name,
|
|
408
|
-
description,
|
|
409
|
-
mimeType: "text/html",
|
|
410
|
-
},
|
|
411
|
-
];
|
|
412
|
-
// Capability-gated elicitation/create url — silently degrade on
|
|
413
|
-
// decline / cancel / timeout; the resource_link in the response
|
|
414
|
-
// remains usable.
|
|
415
|
-
if (clientSupportsUrlElicitation(ctx?.server)) {
|
|
416
|
-
try {
|
|
417
|
-
await ctx.server.elicitInput({
|
|
418
|
-
mode: "url",
|
|
419
|
-
url,
|
|
420
|
-
elicitationId: `pica-subscription-${surface}-${randomUUID()}`,
|
|
421
|
-
message: description,
|
|
422
|
-
}, { timeout: 60_000 });
|
|
423
|
-
}
|
|
424
|
-
catch {
|
|
425
|
-
// User declined / cancelled / elicitation timed out. Not an error
|
|
426
|
-
// — the resource_link below is the universal-floor fallback.
|
|
427
|
-
}
|
|
428
|
-
}
|
|
637
|
+
// ⚠️ THIS TOOL NO LONGER MINTS A CHECKOUT OR A PORTAL LINK.
|
|
638
|
+
//
|
|
639
|
+
// Until 2026-08-21 `action: "subscribe", tier: "indie"` minted a LIVE Stripe
|
|
640
|
+
// Checkout for a £49/month RECURRING subscription — from an agent, in
|
|
641
|
+
// production. The tier price IDs are configured on Production and the Stripe
|
|
642
|
+
// prices were active, so this was a working till for a product pica stopped
|
|
643
|
+
// selling when ADR-276 superseded ADR-205 on 2026-06-26.
|
|
644
|
+
//
|
|
645
|
+
// It also contradicted the Terms of Service, which state that pica does not
|
|
646
|
+
// store a recurring mandate and does not auto-renew. A live path capable of
|
|
647
|
+
// creating one made that sentence false.
|
|
648
|
+
//
|
|
649
|
+
// Nobody was ever charged through it (0 successful charges, 0 subscriptions
|
|
650
|
+
// of any status), which is why this is a refusal and not an incident.
|
|
651
|
+
//
|
|
652
|
+
// Residency is charged by the billing engine when the deep-process gate
|
|
653
|
+
// blocks — a one-off `mode: "payment"` Checkout the user confirms per cycle,
|
|
654
|
+
// priced from RESIDENT_FEE_MINOR_UNITS with inline `price_data`. There is no
|
|
655
|
+
// "subscribe on demand" endpoint to expose, and inventing one here would be
|
|
656
|
+
// the same mistake in the other direction.
|
|
657
|
+
const retiredMessage = "pica does not sell a subscription or a capacity tier — there is nothing " +
|
|
658
|
+
"to subscribe to, upgrade, or cancel. pica is free to walk in and use: " +
|
|
659
|
+
"holding a catalogue, registering works, browsing and searching cost " +
|
|
660
|
+
"nothing, and there is no trial clock. If the user has gone past the free " +
|
|
661
|
+
"deep-processing allowance, pica offers residency at that moment — one " +
|
|
662
|
+
"small flat fee for a 30-day cycle, priced for their region, which they " +
|
|
663
|
+
"confirm each time. Nothing auto-renews, so nothing needs cancelling. " +
|
|
664
|
+
"Point them at the pricing page or their billing settings.";
|
|
429
665
|
return {
|
|
430
|
-
content,
|
|
666
|
+
content: [{ type: "text", text: retiredMessage }],
|
|
431
667
|
structuredContent: {
|
|
432
|
-
kind: "pica.
|
|
668
|
+
kind: "pica.error.retired_action",
|
|
433
669
|
version: 1,
|
|
434
670
|
data: {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
671
|
+
action,
|
|
672
|
+
reason: "subscriptions_and_tiers_retired",
|
|
673
|
+
pricing_url: "https://withpica.com/pricing",
|
|
674
|
+
settings_url: "https://withpica.com/settings?tab=subscription",
|
|
438
675
|
},
|
|
439
676
|
},
|
|
677
|
+
isError: true,
|
|
440
678
|
};
|
|
441
679
|
}
|
|
442
680
|
}
|