@yawlabs/lemonsqueezy-mcp 0.13.2 → 0.13.3

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.
Files changed (3) hide show
  1. package/README.md +396 -396
  2. package/dist/index.js +1 -1
  3. package/package.json +72 -61
package/README.md CHANGED
@@ -1,396 +1,396 @@
1
- # @yawlabs/lemonsqueezy-mcp
2
-
3
- MCP server for the [LemonSqueezy](https://lemonsqueezy.com) API. Manage your store, products, customers, subscriptions, discounts, license keys, and more from any MCP-compatible AI assistant.
4
-
5
- [![Add to Yaw MCP](https://yaw.sh/yaw-mcp-button.svg)](https://yaw.sh/mcp/install?name=LemonSqueezy&command=npx&args=-y%2C%40yawlabs%2Flemonsqueezy-mcp&description=LemonSqueezy%20store%20management%20-%20products%2C%20orders%2C%20subscriptions%2C%20license%20keys&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Flemonsqueezy-mcp)
6
-
7
- One click adds this to your local Yaw MCP config so it's available in every Yaw Terminal session. Or install manually below.
8
-
9
- ## Quick start
10
-
11
- ```bash
12
- npx -y @yawlabs/lemonsqueezy-mcp@latest
13
- ```
14
-
15
- Or one-click install via Smithery:
16
-
17
- ```bash
18
- npx -y @smithery/cli install @yawlabs/lemonsqueezy-mcp --client claude
19
- ```
20
-
21
- Smithery prompts for your env vars (API key, optional guardrails) and writes the config into your client for you.
22
-
23
- ## What it looks like
24
-
25
- Once configured, you can ask your AI assistant store-management questions in plain English and it routes them through the MCP tools:
26
-
27
- ```
28
- You: How much did we make from the "Pro Annual" plan last month?
29
- Claude: [calls ls_list_subscriptions, ls_get_variant, ls_list_subscription_invoices]
30
- Pro Annual brought in $14,280 across 84 active subscriptions in April.
31
- Three of those were upgrades from monthly; none churned.
32
-
33
- You: Refund order #LS-1234 in full.
34
- Claude: [calls ls_get_order to fetch the total, then ls_refund_order with amount = total]
35
- Refunded $99.00 against order LS-1234. The customer's card will see the
36
- credit in 5-10 business days.
37
-
38
- You: Disable license key abc-123 for the customer who reported abuse.
39
- Claude: [calls ls_list_license_keys to find the ID, then ls_update_license_key with disabled: true]
40
- License key disabled. Their existing activations will fail validation
41
- on the next check.
42
- ```
43
-
44
- Guardrails (refund cap, rate limit, store allowlist) catch the obvious mistakes before they reach LemonSqueezy. See [Configuration](#configuration) for the env vars that turn them on.
45
-
46
- ## Setup
47
-
48
- Set your LemonSqueezy API key as an environment variable:
49
-
50
- ```bash
51
- export LEMONSQUEEZY_API_KEY="your-api-key"
52
- ```
53
-
54
- Get your API key from your [LemonSqueezy dashboard](https://app.lemonsqueezy.com/settings/api).
55
-
56
- ### Docker
57
-
58
- A multi-stage `Dockerfile` is included at the repo root. The runtime image is a single bundled file on `node:20-alpine` running as the non-root `node` user, with no port exposed (stdio transport).
59
-
60
- ```bash
61
- docker build -t yawlabs/lemonsqueezy-mcp .
62
- docker run --rm -i -e LEMONSQUEEZY_API_KEY="your-api-key" yawlabs/lemonsqueezy-mcp
63
- ```
64
-
65
- A matching `Containerfile` is provided for Podman users. It is generated from `Dockerfile` via `npm run gen:containerfile`; CI calls `npm run check:containerfile` so a divergent edit fails review rather than drifting silently.
66
-
67
- ### Claude Code
68
-
69
- Add to your MCP config:
70
-
71
- ```json
72
- {
73
- "mcpServers": {
74
- "lemonsqueezy": {
75
- "command": "npx",
76
- "args": ["-y", "@yawlabs/lemonsqueezy-mcp@latest"],
77
- "env": {
78
- "LEMONSQUEEZY_API_KEY": "your-api-key"
79
- }
80
- }
81
- }
82
- }
83
- ```
84
-
85
- ### Claude Desktop
86
-
87
- Add to `claude_desktop_config.json`:
88
-
89
- ```json
90
- {
91
- "mcpServers": {
92
- "lemonsqueezy": {
93
- "command": "npx",
94
- "args": ["-y", "@yawlabs/lemonsqueezy-mcp@latest"],
95
- "env": {
96
- "LEMONSQUEEZY_API_KEY": "your-api-key"
97
- }
98
- }
99
- }
100
- }
101
- ```
102
-
103
- ## Tools (64)
104
-
105
- ### Users
106
- - `ls_get_user` — Get the authenticated user
107
-
108
- ### Stores
109
- - `ls_get_store` — Get a store by ID
110
- - `ls_list_stores` — List all stores
111
-
112
- ### Customers
113
- - `ls_get_customer` — Get a customer by ID
114
- - `ls_list_customers` — List customers (filter by store, email)
115
- - `ls_create_customer` — Create a customer
116
- - `ls_update_customer` — Update a customer
117
- - `ls_archive_customer` — Archive a customer
118
-
119
- ### Products
120
- - `ls_get_product` — Get a product by ID
121
- - `ls_list_products` — List products (filter by store)
122
-
123
- ### Variants
124
- - `ls_get_variant` — Get a variant by ID
125
- - `ls_list_variants` — List variants (filter by product)
126
-
127
- ### Prices
128
- - `ls_get_price` — Get a price by ID
129
- - `ls_list_prices` — List prices (filter by variant)
130
-
131
- ### Files
132
- - `ls_get_file` — Get a file by ID
133
- - `ls_list_files` — List files (filter by variant)
134
-
135
- ### Orders
136
- - `ls_get_order` — Get an order by ID
137
- - `ls_list_orders` — List orders (filter by store, email)
138
- - `ls_generate_order_invoice` — Generate a PDF invoice
139
- - `ls_refund_order` — Issue a refund
140
-
141
- ### Order Items
142
- - `ls_get_order_item` — Get an order item by ID
143
- - `ls_list_order_items` — List order items (filter by order, product, variant)
144
-
145
- ### Subscriptions
146
- - `ls_get_subscription` — Get a subscription by ID
147
- - `ls_list_subscriptions` — List subscriptions (filter by store, status, product, etc.)
148
- - `ls_update_subscription` — Update (plan switch, pause, billing anchor, trial)
149
- - `ls_cancel_subscription` — Cancel a subscription
150
-
151
- ### Subscription Invoices
152
- - `ls_get_subscription_invoice` — Get a subscription invoice by ID
153
- - `ls_list_subscription_invoices` — List subscription invoices (filter by store, subscription, status)
154
- - `ls_generate_subscription_invoice` — Generate a PDF invoice
155
- - `ls_refund_subscription_invoice` — Issue a refund
156
-
157
- ### Subscription Items
158
- - `ls_get_subscription_item` — Get a subscription item by ID
159
- - `ls_list_subscription_items` — List subscription items (filter by subscription, price)
160
- - `ls_update_subscription_item` — Update quantity
161
- - `ls_get_subscription_item_usage` — Get current billing period usage
162
-
163
- ### Usage Records
164
- - `ls_get_usage_record` — Get a usage record by ID
165
- - `ls_list_usage_records` — List usage records (filter by subscription item)
166
- - `ls_create_usage_record` — Report metered usage (increment or set)
167
-
168
- ### Discounts
169
- - `ls_get_discount` — Get a discount by ID
170
- - `ls_list_discounts` — List discounts (filter by store)
171
- - `ls_create_discount` — Create a discount code
172
- - `ls_delete_discount` — Delete a discount
173
-
174
- ### Discount Redemptions
175
- - `ls_get_discount_redemption` — Get a discount redemption by ID
176
- - `ls_list_discount_redemptions` — List redemptions (filter by discount, order)
177
-
178
- ### License Keys
179
- - `ls_get_license_key` — Get a license key by ID
180
- - `ls_list_license_keys` — List license keys (filter by store, order, product)
181
- - `ls_update_license_key` — Update activation limit, expiry, or disabled status
182
-
183
- ### License Key Instances
184
- - `ls_get_license_key_instance` — Get a license key activation by ID
185
- - `ls_list_license_key_instances` — List activations (filter by license key)
186
-
187
- ### Checkouts
188
- - `ls_get_checkout` — Get a checkout by ID
189
- - `ls_list_checkouts` — List checkouts (filter by store, variant)
190
- - `ls_create_checkout` — Create a checkout URL (custom pricing, prefill, discounts)
191
-
192
- ### Webhooks
193
- - `ls_get_webhook` — Get a webhook by ID
194
- - `ls_list_webhooks` — List webhooks (filter by store)
195
- - `ls_create_webhook` — Create a webhook
196
- - `ls_update_webhook` — Update a webhook
197
- - `ls_delete_webhook` — Delete a webhook
198
-
199
- ### License API
200
- - `ls_activate_license` — Activate a license key (no API key required)
201
- - `ls_validate_license` — Validate a license key (no API key required)
202
- - `ls_deactivate_license` — Deactivate a license key instance (no API key required)
203
-
204
- ### Webhook sink (optional)
205
- Bridge to a separate [@yawlabs/lemonsqueezy-webhook-sink](https://github.com/YawLabs/lemonsqueezy-webhook-sink) process so the agent can reconcile against webhooks that actually fired. Tools are always registered; if `LEMONSQUEEZY_SINK_URL` / `LEMONSQUEEZY_SINK_ADMIN_TOKEN` are unset, calls return a clear "not configured" error.
206
-
207
- - `ls_sink_events_list` — List webhook events the sink has received (filter by `since` / `type` / `limit`)
208
- - `ls_sink_event_mark_processed` — Mark a sink event as processed by your consumer (idempotent)
209
- - `ls_sink_stats` — Get total events, unprocessed count, and last-received timestamp
210
-
211
- ## Features
212
-
213
- - **Full API coverage** — All 17 LemonSqueezy API resources with 61 tools, plus 3 bridge tools to an optional [@yawlabs/lemonsqueezy-webhook-sink](https://github.com/YawLabs/lemonsqueezy-webhook-sink) for webhook reconciliation
214
- - **JSON:API support** — Filtering, pagination, and relationship inclusion on all list/get operations
215
- - **Zero runtime dependencies** — Single bundled file for instant `npx` startup
216
- - **License API** — Activate, validate, and deactivate license keys without an API key
217
- - **MCP annotations** — Every tool declares read-only, destructive, and idempotent hints
218
- - **Retry with backoff** — 429 and 5xx retries (idempotent methods only) with exponential backoff, jitter, and a 90s overall-deadline ceiling
219
- - **Guardrails** — opt-in store allowlist, refund cap, destructive-call rate limit, authority-class disable (`LEMONSQUEEZY_DISABLE_CLASSES`), and per-authority-class rate limits (`LEMONSQUEEZY_RATE_LIMIT_PER_CLASS`)
220
- - **Audit log MCP Resource** — `lemonsqueezy://audit-log` exposes the last 1000 destructive-call entries as `application/x-ndjson` for clients without stderr access
221
- - **Structured logging** — opt-in JSON logs to stderr with selectable levels (`error`, `audit`, `all`)
222
-
223
- ## Configuration
224
-
225
- All configuration is via environment variables. Only `LEMONSQUEEZY_API_KEY` (or `LEMONSQUEEZY_API_KEY_COMMAND`) is required; everything else is opt-in.
226
-
227
- | Variable | Purpose |
228
- | --- | --- |
229
- | `LEMONSQUEEZY_API_KEY` | LemonSqueezy API token. |
230
- | `LEMONSQUEEZY_API_KEY_COMMAND` | Command whose stdout produces the API key. Overrides `LEMONSQUEEZY_API_KEY`. Output is cached for 1 hour. Use this to pull short-lived credentials from a vault (`op read`, `gcloud secrets versions access`, etc.) without writing them to env vars. The cache is keyed by the command string, so changing it mid-process refreshes on the next request; it is also invalidated automatically on a 401/403 from the API, so a key rotated upstream takes effect on the next call without waiting for the TTL. |
231
- | `LEMONSQUEEZY_TEST_API_KEY` | Optional test-mode key. When set and non-empty, it takes precedence over `LEMONSQUEEZY_API_KEY` (but not over `LEMONSQUEEZY_API_KEY_COMMAND`). On first activation per process, the server prints a one-line JSON `test_mode` notice to stderr so you can confirm test mode is engaged. Use this to point the server at a sandbox/test store without unsetting your production key. |
232
- | `LEMONSQUEEZY_ALLOWED_STORE_IDS` | Comma-separated allowlist of store IDs. When set: (1) any tool whose input includes a `storeId` rejects calls to a non-allowed store; (2) tools that *accept* a `storeId` filter (e.g. `ls_list_orders`, `ls_list_subscriptions`) require it — calls without one are blocked so a missing filter cannot return data from every store the API key can see. Tools with no `storeId` field at all are **not** gated by this allowlist, in two distinct shapes: (a) ID-targeted tools (`ls_refund_order`, `ls_cancel_subscription`, `ls_archive_customer`, `ls_delete_webhook`, `ls_delete_discount`, `ls_update_license_key`) route by their own resource ID, so the caller must already know the ID; (b) `ls_list_stores` and `ls_list_affiliates` take no scoping ID at all and **return rows from every store the API key can see** — `ls_list_stores` will enumerate stores outside the allowlist. Both say so in their own tool descriptions. Other list-by-parent tools (`ls_list_prices`, `ls_list_files`, `ls_list_variants`, `ls_list_order_items`, `ls_list_discount_redemptions`, `ls_list_license_key_instances`, `ls_list_subscription_items`, `ls_list_usage_records`) require a parent-ID filter when the allowlist is set — a partial mitigation, since that parent can itself belong to a non-allowed store. LemonSqueezy API keys are issued at the account level and authorize access to every store in that account, so this allowlist is the only in-process store boundary the server can enforce. Pair it with `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` / `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` / `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` for defense in depth, and — if your account hosts multiple stores you don't want exposed to the same agent — keep those stores under a separate LemonSqueezy account whose API key isn't reachable from this server. |
233
- | `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` | Non-negative integer. Rejects `ls_refund_order` and `ls_refund_subscription_invoice` calls above this amount. Unset or empty means no cap; **`0` is a valid value and blocks every refund** (the schemas require `amount >= 1`), so use it as a kill switch. The check runs *before* the rate limiters, so a rejected over-cap refund does not consume your destructive or `money`-class budget. |
234
- | `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` | Non-negative integer. Max destructive tool calls per 60-second rolling window. Unset or empty means no limit; **`0` blocks every destructive call.** In-process limit — per MCP server instance, not global; each `npx` cold start resets the window. Counts include every refund, cancellation, archive, and delete tool, plus the input-dependent destructive paths: `ls_update_license_key` calls that set `disabled: true` *or* change `activationLimit`, `ls_update_subscription` calls that pause or switch plan, and `ls_update_customer` calls with `status: "archived"`. |
235
- | `LEMONSQUEEZY_DISABLE_CLASSES` | Comma-separated list of [authority classes](#authority-classes) to refuse outright. Any tool whose class is listed returns a `guardrail_block` before the API call is attempted. Example: `LEMONSQUEEZY_DISABLE_CLASSES=money,recurring,pii` lets an agent run reads but blocks refunds, subscription changes, and customer-record access. Unknown class names throw at server startup. |
236
- | `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` | Per-class rolling rate limits, comma-separated. Each entry is `class:N`, `class:N/m`, or `class:N/h` (bare numbers default to per-minute). Example: `money:2/h,recurring:5/h,key:10/m`. Composes with `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` — both must pass. A limit of `0` blocks every call in that class; classes you don't list are unlimited. Malformed entries throw at server startup. In-process per server instance. |
237
- | `LEMONSQUEEZY_LOG` | Structured-log verbosity to stderr. Set to `all` (or legacy `json`) to log every tool and HTTP call, `audit` to log only destructive-call audit entries plus errors (recommended for production), `error` to log only failures. Unset: no logs. Destructive calls are tagged `audit: true` and include their inputs. Failure entries carry a `status` that identifies the cause: `guardrail_block` (operator policy refused the call), `validation_error` (the client sent a malformed request, e.g. an update with no fields to change), `exception` (something faulted), plus `timeout` / `network_error` and raw HTTP status codes. |
238
- | `LEMONSQUEEZY_SINK_URL` | Base URL of an optional [@yawlabs/lemonsqueezy-webhook-sink](https://github.com/YawLabs/lemonsqueezy-webhook-sink) instance (e.g. `https://webhooks.example.com`). Trailing slashes are stripped. Enables the `ls_sink_*` reconciliation tools below. Unset: the tools are still registered but return a "not configured" error when called. |
239
- | `LEMONSQUEEZY_SINK_ADMIN_TOKEN` | Bearer token for the sink's admin endpoints. Must match the sink's `WEBHOOK_SINK_ADMIN_TOKEN`. Required when `LEMONSQUEEZY_SINK_URL` is set; if the sink itself was started without an admin token, its admin endpoints return 404 and `ls_sink_*` calls surface that diagnostically. |
240
-
241
- ### Logging format
242
-
243
- Each line: `{ts, event, tool?, method?, path?, status, latency_ms, request_id?, error?, audit?, inputs?}`. Stdout is reserved for the MCP protocol — never log there.
244
-
245
- ### Error decoration
246
-
247
- HTTP errors include the upstream `X-Request-Id` when present, so support tickets to LemonSqueezy can reference the exact call.
248
-
249
- ## Authority classes
250
-
251
- **The strongest access control LemonSqueezy itself exposes is the API key boundary.** A LemonSqueezy API key authorizes its full account — every store, every tool — and the only way to deny a class of authority through LemonSqueezy is to not give the API key to the agent in the first place. LemonSqueezy's team-membership UI scopes which humans can do which actions in the dashboard, but the public API key inherits the full authority of the account it was issued under; there's no "this key can read but not refund" toggle. So the authoritative boundary, as far as the upstream API is concerned, is *which API key the agent has*.
252
-
253
- That means the env vars below are the primary in-process control surface for anything LemonSqueezy can't gate by itself — per-class rate ceilings (`RATE_LIMIT_PER_CLASS`), deploy-time class disables (`DISABLE_CLASSES`), refund caps (`MAX_REFUND_AMOUNT_CENTS`), and the audit log. They are belt-and-braces in the sense that an operator who can change the server's env can remove them; they are load-bearing in the sense that LemonSqueezy has no equivalent. If you need an agent that genuinely cannot reach a store or a class of action, the durable answer is a separate LemonSqueezy account whose key is never handed to that agent.
254
-
255
- Every tool is tagged with an **authority class** — a label for the kind of business authority a caller needs to invoke it. The class is separate from the binary destructive/read-only annotation: a customer-record read and a product list are both reads, but only one returns PII; a checkout creation and a refund are both writes, but only one moves money.
256
-
257
- | Class | What it covers | Example tools |
258
- | --- | --- | --- |
259
- | `read` | Safe reads (list/get) that don't return customer PII as the primary payload. | `ls_list_orders`, `ls_get_product`, `ls_validate_license` |
260
- | `pii` | Reads or writes whose primary payload is a customer record. | `ls_list_customers`, `ls_create_customer`, `ls_archive_customer` |
261
- | `mutate` | Safe mutations: checkouts, discounts, invoice generation, usage records. | `ls_create_checkout`, `ls_create_discount`, `ls_generate_order_invoice` |
262
- | `money` | Money movement. Irreversible at the payment layer. | `ls_refund_order`, `ls_refund_subscription_invoice` |
263
- | `recurring` | Subscription state changes that affect recurring revenue. | `ls_update_subscription`, `ls_cancel_subscription`, `ls_update_subscription_item` |
264
- | `key` | License-key admin (activate, deactivate, disable, change activation limit). | `ls_update_license_key`, `ls_activate_license`, `ls_deactivate_license` |
265
- | `webhook` | Webhook configuration — affects the trust surface other systems rely on. | `ls_create_webhook`, `ls_update_webhook`, `ls_delete_webhook` |
266
-
267
- Note: `ls_get_order` returns customer fields incidentally, but its primary payload is the order — it stays in `read`, not `pii`. The class is reserved for tools whose *primary purpose* is the customer record. If you need to deny all access to customer-shaped data, set `LEMONSQUEEZY_DISABLE_CLASSES=pii` and — if the agent must never under any circumstances touch that data — also issue its API key from a separate LemonSqueezy account that doesn't host customer records you care about.
268
-
269
- The two opt-in env vars that consume this taxonomy:
270
-
271
- - `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` — caps the call rate per class. LemonSqueezy permissions cannot express "max 2 refunds per hour"; this is the only place that policy can live. **This is the load-bearing one for runaway-agent prevention.**
272
- - `LEMONSQUEEZY_DISABLE_CLASSES` — blocks a class outright. Useful when fast deploy-time toggles matter — flipping `DISABLE_CLASSES=pii,mutate,money,recurring,key,webhook` to lock an analytics deployment into pure reads is a one-line config change. An operator who can change the server's env can also remove this gate, so for an authoritative deny use a separate LemonSqueezy account whose API key the agent never sees.
273
-
274
- Both are opt-in; with neither set, behavior is unchanged from prior versions.
275
-
276
- ## Resources
277
-
278
- The server exposes one MCP Resource for clients that prefer structural retrieval over parsing stderr:
279
-
280
- | URI | MIME type | Contents |
281
- | --- | --- | --- |
282
- | `lemonsqueezy://audit-log` | `application/x-ndjson` | The most recent destructive tool calls and outcomes (rate-limit blocks, refund-cap blocks, exceptions, successes). Bounded ring buffer of the last 1000 entries, most-recent-first, resets on server restart. Redaction runs on the input payload before it reaches the buffer: any object key whose name matches a credential / PII pattern (`secret`, `password`, `token`, `api_key`, `bearer`, `authorization`, `signing_secret`, `private_key`, `pin`, `ssn`, `social_security_number`, `credit_card`, `card_number`, `cvv`, `cvc` — case-insensitive, whole-word) AND any string value matching the JWT bearer-token shape (`eyJ…`-prefixed, three base64url segments) is replaced with `[REDACTED]`. Ordinary identifiers (`licenseKey`, `instanceId`, `storeId`, `orderId`, `webhookId`) and UUID-shaped values are preserved. |
283
-
284
- ## Operating the server unattended
285
-
286
- For unattended/agentic use against a live store, we recommend:
287
-
288
- 1. Issue an API key under a LemonSqueezy account that hosts only the store(s) the agent is allowed to touch — LemonSqueezy doesn't expose per-store API-key scoping, so account separation is the durable store boundary. Set `LEMONSQUEEZY_ALLOWED_STORE_IDS` to the same set as a belt-and-braces in-process gate on the tools that take a `storeId`.
289
- 2. Set `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` to a per-call cap well below any single-refund expectation.
290
- 3. Set `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` to a small number (e.g. 5/min) as a runaway-agent circuit breaker. For finer control, add `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS=money:2/h,recurring:5/h,key:10/m` so each [authority class](#authority-classes) has its own ceiling.
291
- 4. If a class shouldn't be reachable at all (e.g. an analytics agent that needs only `read`), set `LEMONSQUEEZY_DISABLE_CLASSES` to the classes you want refused. The server rejects them before the API call is built. For an irrevocable deny, also issue the agent's API key from a separate LemonSqueezy account.
292
- 5. Set `LEMONSQUEEZY_LOG=audit` and ship stderr to your log aggregator. The `audit` level keeps every destructive-call entry plus errors but drops successful reads so log volume stays bounded over weeks of operation. Alert on `status: "guardrail_block"` or elevated error rates per tool. Use `LEMONSQUEEZY_LOG=all` while debugging.
293
- 6. Run `LEMONSQUEEZY_API_KEY_COMMAND` against a vault-backed secret so credentials can rotate without restarting the server process. The API client invalidates its in-process key cache automatically on a 401/403, so a rotated upstream key picks up on the next request rather than waiting on the 1h TTL.
294
-
295
- What the server does **not** do and you must own at the caller level:
296
-
297
- - **Idempotency / dedupe store** — MCP servers are stateless subprocesses; cross-invocation dedupe belongs in your agent or orchestrator.
298
- - **Webhook reconciliation** — subscribe to LemonSqueezy webhooks in a separate long-running process to reconcile state when API writes succeed but the response is lost. See [@yawlabs/lemonsqueezy-webhook-sink](https://github.com/YawLabs/lemonsqueezy-webhook-sink) for a ready-made sink.
299
- - **Metrics / dashboards** — the server emits structured logs; derive metrics in your log pipeline.
300
-
301
- See [SEMVER.md](./SEMVER.md) for the versioning policy.
302
-
303
- ## Development
304
-
305
- ```bash
306
- npm install
307
- npm run lint
308
- npm test # full unit + handler suite
309
- npm run test:integration # requires LEMONSQUEEZY_TEST_API_KEY + LEMONSQUEEZY_TEST_STORE_ID
310
- ```
311
-
312
- `Containerfile` is generated from `Dockerfile`. After editing `Dockerfile`:
313
-
314
- ```bash
315
- npm run gen:containerfile # regenerate Containerfile
316
- npm run check:containerfile # CI runs this; non-zero exit means the two have drifted
317
- ```
318
-
319
- ## Running on oam.js (optional)
320
-
321
- [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 64 tools, the `lemonsqueezy://audit-log` resource, working `fetch`, and guardrail rejections with error text identical to Node.
322
-
323
- **oam 0.9.0 is the minimum.** Older releases ran `child_process.execFile` arguments through a shell, which was reachable here whenever `LEMONSQUEEZY_API_KEY_COMMAND` is configured -- that feature shells out to fetch the key, and its arguments were re-split by a shell. The launcher enforces the floor: given an older oam it falls back to Node and says so on stderr, and `LEMONSQUEEZY_MCP_RUNTIME=oam` turns that into a hard error.
324
-
325
- ### Sandboxing (opt-in)
326
-
327
- Set `LEMONSQUEEZY_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.lemonsqueezy.com` (plus the host of `LEMONSQUEEZY_SINK_URL` when set), filesystem denied outright, and child-process denied unless `LEMONSQUEEZY_API_KEY_COMMAND` is configured.
328
-
329
- It is opt-in rather than default because a wrong grant does not fail loudly. oam denies a non-granted environment variable by making it **absent** from `process.env` rather than throwing, so an under-granted `LEMONSQUEEZY_API_KEY` reads as "unauthenticated" rather than "denied". The env allow-list in the launcher is derived from what the shipped bundle actually reads -- if you add a new `process.env` lookup, extend that list with it.
330
-
331
- ```jsonc
332
- {
333
- "mcpServers": {
334
- "lemonsqueezy": {
335
- "command": "oam",
336
- "args": ["run", "/path/to/lemonsqueezy-mcp/dist/index.js"],
337
- "env": { "LEMONSQUEEZY_API_KEY": "..." }
338
- }
339
- }
340
- }
341
- ```
342
-
343
- Note the `--` separator if you pass arguments to the server rather than to oam: `oam run dist/index.js -- version`.
344
-
345
- **Node stays the default, deliberately.** An MCP client cold-starts this server once per session, so startup is the cost that actually gets paid — and on the machine this was measured on, Node won: **196ms median against 424ms for `oam run`** (8 runs each, `version` subcommand, which exercises full boot plus tool registration). Making oam the default would mean either a launcher that probes for it on every start — a cost paid by everyone, including the majority who do not have oam installed — or making oam a hard requirement, which would break `npx @yawlabs/lemonsqueezy-mcp` for every user without it, since oam is not distributed on npm. Neither is worth it to reach a runtime that is slower here. Measure on your own hardware before concluding anything; if oam wins on yours, the config above is all you need.
346
-
347
- Two places oam *does* win for this repo, both opt-in and neither touching the npm package:
348
-
349
- - **`npm run check:oam`** — type-checks via `oam check` (tsgo, TypeScript 7 native). Measured 2878ms against 4406ms for `tsc --noEmit`, same clean result. `npx tsc --noEmit` remains the portable default and is what the pre-commit checklist calls for.
350
- - **`npm run build:binary:oam`** — builds the standalone binary via `oam compile` instead of the Node SEA path. Measured 57.14 MB. Writes to the same `bin/<platform>-<arch>/` path as `npm run build:binary`, so the release staging script consumes either unchanged — run one or the other, not both. If you redistribute that binary it embeds oam's runtime, so ship oam's `LICENSE`, `NOTICE` and `THIRD_PARTY_LICENSES.md` with it.
351
-
352
- The source stays runtime-agnostic on purpose: no `oam:` imports anywhere, and tests stay on `node:test`. That is what keeps the Node fallback real rather than nominal — an `oam:test` or `oam:`-prefixed import would make "falls back to Node" false the moment it landed. Any `oam` invocation writes a bytecode cache to `oam/` in the working directory; that path is gitignored.
353
-
354
- ## Releasing
355
-
356
- Two paths from a clean checkout of `main`. Both produce the same artifact (npm publish with provenance + GitHub release).
357
-
358
- ### 1. Tag-and-let-CI (preferred)
359
-
360
- ```bash
361
- # 1. Bump version
362
- npm version X.Y.Z --no-git-tag-version
363
-
364
- # 2. Commit
365
- git add package.json && git commit -m "vX.Y.Z"
366
-
367
- # 3. Annotated tag (lightweight tags are silently skipped by --follow-tags)
368
- git tag -a vX.Y.Z -m "vX.Y.Z"
369
-
370
- # 4. Push commit + tag
371
- git push origin main --follow-tags
372
-
373
- # 5. Confirm the Release workflow fired (not just CI on the bump commit)
374
- gh run list --limit 2
375
- ```
376
-
377
- The tag push triggers `.github/workflows/release.yml`, which runs `release.sh` in CI mode: lint, test, build, npm publish (with `--provenance`) using the org-level `NPM_TOKEN` secret, then GitHub release creation, then a smoke test against the published tarball, then a publish to the [Official MCP Registry](https://registry.modelcontextprotocol.io) via GitHub OIDC (no `MCP_*` secret needed; the namespace `io.github.YawLabs/*` is authorized purely from the OIDC `repository_owner` claim). No local `npm login` needed.
378
-
379
- ### 2. Local end-to-end
380
-
381
- ```bash
382
- ./release.sh X.Y.Z
383
- ```
384
-
385
- Does the same steps 1–7 on the workstation: lint, test, build, bump, commit, annotated tag, push, npm publish, GitHub release, verify. Idempotent — safe to re-run with the same version after a partial failure. Requires one-time setup:
386
-
387
- ```bash
388
- npm login --auth-type=web # publisher of @yawlabs/lemonsqueezy-mcp
389
- gh auth login # GitHub CLI for the release-creation step
390
- ```
391
-
392
- The local path does **not** publish to the Official MCP Registry — that step lives only in CI and depends on a GitHub Actions OIDC token. To push a locally-released version to the registry, install [`mcp-publisher`](https://github.com/modelcontextprotocol/registry/releases), run `mcp-publisher login github` (interactive OAuth), then `mcp-publisher publish` from the repo root.
393
-
394
- ## License
395
-
396
- MIT
1
+ # @yawlabs/lemonsqueezy-mcp
2
+
3
+ MCP server for the [LemonSqueezy](https://lemonsqueezy.com) API. Manage your store, products, customers, subscriptions, discounts, license keys, and more from any MCP-compatible AI assistant.
4
+
5
+ [![Add to Yaw MCP](https://yaw.sh/yaw-mcp-button.svg)](https://yaw.sh/mcp/install?name=LemonSqueezy&command=npx&args=-y%2C%40yawlabs%2Flemonsqueezy-mcp&description=LemonSqueezy%20store%20management%20-%20products%2C%20orders%2C%20subscriptions%2C%20license%20keys&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Flemonsqueezy-mcp) [![Follow @TokenLimitNews on X](https://img.shields.io/badge/follow-%40TokenLimitNews-000000?logo=x&logoColor=white)](https://x.com/TokenLimitNews)
6
+
7
+ One click adds this to your local Yaw MCP config so it's available in every Yaw Terminal session. Or install manually below.
8
+
9
+ ## Quick start
10
+
11
+ ```bash
12
+ npx -y @yawlabs/lemonsqueezy-mcp@latest
13
+ ```
14
+
15
+ Or one-click install via Smithery:
16
+
17
+ ```bash
18
+ npx -y @smithery/cli install @yawlabs/lemonsqueezy-mcp --client claude
19
+ ```
20
+
21
+ Smithery prompts for your env vars (API key, optional guardrails) and writes the config into your client for you.
22
+
23
+ ## What it looks like
24
+
25
+ Once configured, you can ask your AI assistant store-management questions in plain English and it routes them through the MCP tools:
26
+
27
+ ```
28
+ You: How much did we make from the "Pro Annual" plan last month?
29
+ Claude: [calls ls_list_subscriptions, ls_get_variant, ls_list_subscription_invoices]
30
+ Pro Annual brought in $14,280 across 84 active subscriptions in April.
31
+ Three of those were upgrades from monthly; none churned.
32
+
33
+ You: Refund order #LS-1234 in full.
34
+ Claude: [calls ls_get_order to fetch the total, then ls_refund_order with amount = total]
35
+ Refunded $99.00 against order LS-1234. The customer's card will see the
36
+ credit in 5-10 business days.
37
+
38
+ You: Disable license key abc-123 for the customer who reported abuse.
39
+ Claude: [calls ls_list_license_keys to find the ID, then ls_update_license_key with disabled: true]
40
+ License key disabled. Their existing activations will fail validation
41
+ on the next check.
42
+ ```
43
+
44
+ Guardrails (refund cap, rate limit, store allowlist) catch the obvious mistakes before they reach LemonSqueezy. See [Configuration](#configuration) for the env vars that turn them on.
45
+
46
+ ## Setup
47
+
48
+ Set your LemonSqueezy API key as an environment variable:
49
+
50
+ ```bash
51
+ export LEMONSQUEEZY_API_KEY="your-api-key"
52
+ ```
53
+
54
+ Get your API key from your [LemonSqueezy dashboard](https://app.lemonsqueezy.com/settings/api).
55
+
56
+ ### Docker
57
+
58
+ A multi-stage `Dockerfile` is included at the repo root. The runtime image is a single bundled file on `node:20-alpine` running as the non-root `node` user, with no port exposed (stdio transport).
59
+
60
+ ```bash
61
+ docker build -t yawlabs/lemonsqueezy-mcp .
62
+ docker run --rm -i -e LEMONSQUEEZY_API_KEY="your-api-key" yawlabs/lemonsqueezy-mcp
63
+ ```
64
+
65
+ A matching `Containerfile` is provided for Podman users. It is generated from `Dockerfile` via `npm run gen:containerfile`; CI calls `npm run check:containerfile` so a divergent edit fails review rather than drifting silently.
66
+
67
+ ### Claude Code
68
+
69
+ Add to your MCP config:
70
+
71
+ ```json
72
+ {
73
+ "mcpServers": {
74
+ "lemonsqueezy": {
75
+ "command": "npx",
76
+ "args": ["-y", "@yawlabs/lemonsqueezy-mcp@latest"],
77
+ "env": {
78
+ "LEMONSQUEEZY_API_KEY": "your-api-key"
79
+ }
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ ### Claude Desktop
86
+
87
+ Add to `claude_desktop_config.json`:
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "lemonsqueezy": {
93
+ "command": "npx",
94
+ "args": ["-y", "@yawlabs/lemonsqueezy-mcp@latest"],
95
+ "env": {
96
+ "LEMONSQUEEZY_API_KEY": "your-api-key"
97
+ }
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
103
+ ## Tools (64)
104
+
105
+ ### Users
106
+ - `ls_get_user` — Get the authenticated user
107
+
108
+ ### Stores
109
+ - `ls_get_store` — Get a store by ID
110
+ - `ls_list_stores` — List all stores
111
+
112
+ ### Customers
113
+ - `ls_get_customer` — Get a customer by ID
114
+ - `ls_list_customers` — List customers (filter by store, email)
115
+ - `ls_create_customer` — Create a customer
116
+ - `ls_update_customer` — Update a customer
117
+ - `ls_archive_customer` — Archive a customer
118
+
119
+ ### Products
120
+ - `ls_get_product` — Get a product by ID
121
+ - `ls_list_products` — List products (filter by store)
122
+
123
+ ### Variants
124
+ - `ls_get_variant` — Get a variant by ID
125
+ - `ls_list_variants` — List variants (filter by product)
126
+
127
+ ### Prices
128
+ - `ls_get_price` — Get a price by ID
129
+ - `ls_list_prices` — List prices (filter by variant)
130
+
131
+ ### Files
132
+ - `ls_get_file` — Get a file by ID
133
+ - `ls_list_files` — List files (filter by variant)
134
+
135
+ ### Orders
136
+ - `ls_get_order` — Get an order by ID
137
+ - `ls_list_orders` — List orders (filter by store, email)
138
+ - `ls_generate_order_invoice` — Generate a PDF invoice
139
+ - `ls_refund_order` — Issue a refund
140
+
141
+ ### Order Items
142
+ - `ls_get_order_item` — Get an order item by ID
143
+ - `ls_list_order_items` — List order items (filter by order, product, variant)
144
+
145
+ ### Subscriptions
146
+ - `ls_get_subscription` — Get a subscription by ID
147
+ - `ls_list_subscriptions` — List subscriptions (filter by store, status, product, etc.)
148
+ - `ls_update_subscription` — Update (plan switch, pause, billing anchor, trial)
149
+ - `ls_cancel_subscription` — Cancel a subscription
150
+
151
+ ### Subscription Invoices
152
+ - `ls_get_subscription_invoice` — Get a subscription invoice by ID
153
+ - `ls_list_subscription_invoices` — List subscription invoices (filter by store, subscription, status)
154
+ - `ls_generate_subscription_invoice` — Generate a PDF invoice
155
+ - `ls_refund_subscription_invoice` — Issue a refund
156
+
157
+ ### Subscription Items
158
+ - `ls_get_subscription_item` — Get a subscription item by ID
159
+ - `ls_list_subscription_items` — List subscription items (filter by subscription, price)
160
+ - `ls_update_subscription_item` — Update quantity
161
+ - `ls_get_subscription_item_usage` — Get current billing period usage
162
+
163
+ ### Usage Records
164
+ - `ls_get_usage_record` — Get a usage record by ID
165
+ - `ls_list_usage_records` — List usage records (filter by subscription item)
166
+ - `ls_create_usage_record` — Report metered usage (increment or set)
167
+
168
+ ### Discounts
169
+ - `ls_get_discount` — Get a discount by ID
170
+ - `ls_list_discounts` — List discounts (filter by store)
171
+ - `ls_create_discount` — Create a discount code
172
+ - `ls_delete_discount` — Delete a discount
173
+
174
+ ### Discount Redemptions
175
+ - `ls_get_discount_redemption` — Get a discount redemption by ID
176
+ - `ls_list_discount_redemptions` — List redemptions (filter by discount, order)
177
+
178
+ ### License Keys
179
+ - `ls_get_license_key` — Get a license key by ID
180
+ - `ls_list_license_keys` — List license keys (filter by store, order, product)
181
+ - `ls_update_license_key` — Update activation limit, expiry, or disabled status
182
+
183
+ ### License Key Instances
184
+ - `ls_get_license_key_instance` — Get a license key activation by ID
185
+ - `ls_list_license_key_instances` — List activations (filter by license key)
186
+
187
+ ### Checkouts
188
+ - `ls_get_checkout` — Get a checkout by ID
189
+ - `ls_list_checkouts` — List checkouts (filter by store, variant)
190
+ - `ls_create_checkout` — Create a checkout URL (custom pricing, prefill, discounts)
191
+
192
+ ### Webhooks
193
+ - `ls_get_webhook` — Get a webhook by ID
194
+ - `ls_list_webhooks` — List webhooks (filter by store)
195
+ - `ls_create_webhook` — Create a webhook
196
+ - `ls_update_webhook` — Update a webhook
197
+ - `ls_delete_webhook` — Delete a webhook
198
+
199
+ ### License API
200
+ - `ls_activate_license` — Activate a license key (no API key required)
201
+ - `ls_validate_license` — Validate a license key (no API key required)
202
+ - `ls_deactivate_license` — Deactivate a license key instance (no API key required)
203
+
204
+ ### Webhook sink (optional)
205
+ Bridge to a separate [@yawlabs/lemonsqueezy-webhook-sink](https://github.com/YawLabs/lemonsqueezy-webhook-sink) process so the agent can reconcile against webhooks that actually fired. Tools are always registered; if `LEMONSQUEEZY_SINK_URL` / `LEMONSQUEEZY_SINK_ADMIN_TOKEN` are unset, calls return a clear "not configured" error.
206
+
207
+ - `ls_sink_events_list` — List webhook events the sink has received (filter by `since` / `type` / `limit`)
208
+ - `ls_sink_event_mark_processed` — Mark a sink event as processed by your consumer (idempotent)
209
+ - `ls_sink_stats` — Get total events, unprocessed count, and last-received timestamp
210
+
211
+ ## Features
212
+
213
+ - **Full API coverage** — All 17 LemonSqueezy API resources with 61 tools, plus 3 bridge tools to an optional [@yawlabs/lemonsqueezy-webhook-sink](https://github.com/YawLabs/lemonsqueezy-webhook-sink) for webhook reconciliation
214
+ - **JSON:API support** — Filtering, pagination, and relationship inclusion on all list/get operations
215
+ - **Zero runtime dependencies** — Single bundled file for instant `npx` startup
216
+ - **License API** — Activate, validate, and deactivate license keys without an API key
217
+ - **MCP annotations** — Every tool declares read-only, destructive, and idempotent hints
218
+ - **Retry with backoff** — 429 and 5xx retries (idempotent methods only) with exponential backoff, jitter, and a 90s overall-deadline ceiling
219
+ - **Guardrails** — opt-in store allowlist, refund cap, destructive-call rate limit, authority-class disable (`LEMONSQUEEZY_DISABLE_CLASSES`), and per-authority-class rate limits (`LEMONSQUEEZY_RATE_LIMIT_PER_CLASS`)
220
+ - **Audit log MCP Resource** — `lemonsqueezy://audit-log` exposes the last 1000 destructive-call entries as `application/x-ndjson` for clients without stderr access
221
+ - **Structured logging** — opt-in JSON logs to stderr with selectable levels (`error`, `audit`, `all`)
222
+
223
+ ## Configuration
224
+
225
+ All configuration is via environment variables. Only `LEMONSQUEEZY_API_KEY` (or `LEMONSQUEEZY_API_KEY_COMMAND`) is required; everything else is opt-in.
226
+
227
+ | Variable | Purpose |
228
+ | --- | --- |
229
+ | `LEMONSQUEEZY_API_KEY` | LemonSqueezy API token. |
230
+ | `LEMONSQUEEZY_API_KEY_COMMAND` | Command whose stdout produces the API key. Overrides `LEMONSQUEEZY_API_KEY`. Output is cached for 1 hour. Use this to pull short-lived credentials from a vault (`op read`, `gcloud secrets versions access`, etc.) without writing them to env vars. The cache is keyed by the command string, so changing it mid-process refreshes on the next request; it is also invalidated automatically on a 401/403 from the API, so a key rotated upstream takes effect on the next call without waiting for the TTL. |
231
+ | `LEMONSQUEEZY_TEST_API_KEY` | Optional test-mode key. When set and non-empty, it takes precedence over `LEMONSQUEEZY_API_KEY` (but not over `LEMONSQUEEZY_API_KEY_COMMAND`). On first activation per process, the server prints a one-line JSON `test_mode` notice to stderr so you can confirm test mode is engaged. Use this to point the server at a sandbox/test store without unsetting your production key. |
232
+ | `LEMONSQUEEZY_ALLOWED_STORE_IDS` | Comma-separated allowlist of store IDs. When set: (1) any tool whose input includes a `storeId` rejects calls to a non-allowed store; (2) tools that *accept* a `storeId` filter (e.g. `ls_list_orders`, `ls_list_subscriptions`) require it — calls without one are blocked so a missing filter cannot return data from every store the API key can see. Tools with no `storeId` field at all are **not** gated by this allowlist, in two distinct shapes: (a) ID-targeted tools (`ls_refund_order`, `ls_cancel_subscription`, `ls_archive_customer`, `ls_delete_webhook`, `ls_delete_discount`, `ls_update_license_key`) route by their own resource ID, so the caller must already know the ID; (b) `ls_list_stores` and `ls_list_affiliates` take no scoping ID at all and **return rows from every store the API key can see** — `ls_list_stores` will enumerate stores outside the allowlist. Both say so in their own tool descriptions. Other list-by-parent tools (`ls_list_prices`, `ls_list_files`, `ls_list_variants`, `ls_list_order_items`, `ls_list_discount_redemptions`, `ls_list_license_key_instances`, `ls_list_subscription_items`, `ls_list_usage_records`) require a parent-ID filter when the allowlist is set — a partial mitigation, since that parent can itself belong to a non-allowed store. LemonSqueezy API keys are issued at the account level and authorize access to every store in that account, so this allowlist is the only in-process store boundary the server can enforce. Pair it with `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` / `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` / `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` for defense in depth, and — if your account hosts multiple stores you don't want exposed to the same agent — keep those stores under a separate LemonSqueezy account whose API key isn't reachable from this server. |
233
+ | `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` | Non-negative integer. Rejects `ls_refund_order` and `ls_refund_subscription_invoice` calls above this amount. Unset or empty means no cap; **`0` is a valid value and blocks every refund** (the schemas require `amount >= 1`), so use it as a kill switch. The check runs *before* the rate limiters, so a rejected over-cap refund does not consume your destructive or `money`-class budget. |
234
+ | `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` | Non-negative integer. Max destructive tool calls per 60-second rolling window. Unset or empty means no limit; **`0` blocks every destructive call.** In-process limit — per MCP server instance, not global; each `npx` cold start resets the window. Counts include every refund, cancellation, archive, and delete tool, plus the input-dependent destructive paths: `ls_update_license_key` calls that set `disabled: true` *or* change `activationLimit`, `ls_update_subscription` calls that pause or switch plan, and `ls_update_customer` calls with `status: "archived"`. |
235
+ | `LEMONSQUEEZY_DISABLE_CLASSES` | Comma-separated list of [authority classes](#authority-classes) to refuse outright. Any tool whose class is listed returns a `guardrail_block` before the API call is attempted. Example: `LEMONSQUEEZY_DISABLE_CLASSES=money,recurring,pii` lets an agent run reads but blocks refunds, subscription changes, and customer-record access. Unknown class names throw at server startup. |
236
+ | `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` | Per-class rolling rate limits, comma-separated. Each entry is `class:N`, `class:N/m`, or `class:N/h` (bare numbers default to per-minute). Example: `money:2/h,recurring:5/h,key:10/m`. Composes with `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` — both must pass. A limit of `0` blocks every call in that class; classes you don't list are unlimited. Malformed entries throw at server startup. In-process per server instance. |
237
+ | `LEMONSQUEEZY_LOG` | Structured-log verbosity to stderr. Set to `all` (or legacy `json`) to log every tool and HTTP call, `audit` to log only destructive-call audit entries plus errors (recommended for production), `error` to log only failures. Unset: no logs. Destructive calls are tagged `audit: true` and include their inputs. Failure entries carry a `status` that identifies the cause: `guardrail_block` (operator policy refused the call), `validation_error` (the client sent a malformed request, e.g. an update with no fields to change), `exception` (something faulted), plus `timeout` / `network_error` and raw HTTP status codes. |
238
+ | `LEMONSQUEEZY_SINK_URL` | Base URL of an optional [@yawlabs/lemonsqueezy-webhook-sink](https://github.com/YawLabs/lemonsqueezy-webhook-sink) instance (e.g. `https://webhooks.example.com`). Trailing slashes are stripped. Enables the `ls_sink_*` reconciliation tools below. Unset: the tools are still registered but return a "not configured" error when called. |
239
+ | `LEMONSQUEEZY_SINK_ADMIN_TOKEN` | Bearer token for the sink's admin endpoints. Must match the sink's `WEBHOOK_SINK_ADMIN_TOKEN`. Required when `LEMONSQUEEZY_SINK_URL` is set; if the sink itself was started without an admin token, its admin endpoints return 404 and `ls_sink_*` calls surface that diagnostically. |
240
+
241
+ ### Logging format
242
+
243
+ Each line: `{ts, event, tool?, method?, path?, status, latency_ms, request_id?, error?, audit?, inputs?}`. Stdout is reserved for the MCP protocol — never log there.
244
+
245
+ ### Error decoration
246
+
247
+ HTTP errors include the upstream `X-Request-Id` when present, so support tickets to LemonSqueezy can reference the exact call.
248
+
249
+ ## Authority classes
250
+
251
+ **The strongest access control LemonSqueezy itself exposes is the API key boundary.** A LemonSqueezy API key authorizes its full account — every store, every tool — and the only way to deny a class of authority through LemonSqueezy is to not give the API key to the agent in the first place. LemonSqueezy's team-membership UI scopes which humans can do which actions in the dashboard, but the public API key inherits the full authority of the account it was issued under; there's no "this key can read but not refund" toggle. So the authoritative boundary, as far as the upstream API is concerned, is *which API key the agent has*.
252
+
253
+ That means the env vars below are the primary in-process control surface for anything LemonSqueezy can't gate by itself — per-class rate ceilings (`RATE_LIMIT_PER_CLASS`), deploy-time class disables (`DISABLE_CLASSES`), refund caps (`MAX_REFUND_AMOUNT_CENTS`), and the audit log. They are belt-and-braces in the sense that an operator who can change the server's env can remove them; they are load-bearing in the sense that LemonSqueezy has no equivalent. If you need an agent that genuinely cannot reach a store or a class of action, the durable answer is a separate LemonSqueezy account whose key is never handed to that agent.
254
+
255
+ Every tool is tagged with an **authority class** — a label for the kind of business authority a caller needs to invoke it. The class is separate from the binary destructive/read-only annotation: a customer-record read and a product list are both reads, but only one returns PII; a checkout creation and a refund are both writes, but only one moves money.
256
+
257
+ | Class | What it covers | Example tools |
258
+ | --- | --- | --- |
259
+ | `read` | Safe reads (list/get) that don't return customer PII as the primary payload. | `ls_list_orders`, `ls_get_product`, `ls_validate_license` |
260
+ | `pii` | Reads or writes whose primary payload is a customer record. | `ls_list_customers`, `ls_create_customer`, `ls_archive_customer` |
261
+ | `mutate` | Safe mutations: checkouts, discounts, invoice generation, usage records. | `ls_create_checkout`, `ls_create_discount`, `ls_generate_order_invoice` |
262
+ | `money` | Money movement. Irreversible at the payment layer. | `ls_refund_order`, `ls_refund_subscription_invoice` |
263
+ | `recurring` | Subscription state changes that affect recurring revenue. | `ls_update_subscription`, `ls_cancel_subscription`, `ls_update_subscription_item` |
264
+ | `key` | License-key admin (activate, deactivate, disable, change activation limit). | `ls_update_license_key`, `ls_activate_license`, `ls_deactivate_license` |
265
+ | `webhook` | Webhook configuration — affects the trust surface other systems rely on. | `ls_create_webhook`, `ls_update_webhook`, `ls_delete_webhook` |
266
+
267
+ Note: `ls_get_order` returns customer fields incidentally, but its primary payload is the order — it stays in `read`, not `pii`. The class is reserved for tools whose *primary purpose* is the customer record. If you need to deny all access to customer-shaped data, set `LEMONSQUEEZY_DISABLE_CLASSES=pii` and — if the agent must never under any circumstances touch that data — also issue its API key from a separate LemonSqueezy account that doesn't host customer records you care about.
268
+
269
+ The two opt-in env vars that consume this taxonomy:
270
+
271
+ - `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` — caps the call rate per class. LemonSqueezy permissions cannot express "max 2 refunds per hour"; this is the only place that policy can live. **This is the load-bearing one for runaway-agent prevention.**
272
+ - `LEMONSQUEEZY_DISABLE_CLASSES` — blocks a class outright. Useful when fast deploy-time toggles matter — flipping `DISABLE_CLASSES=pii,mutate,money,recurring,key,webhook` to lock an analytics deployment into pure reads is a one-line config change. An operator who can change the server's env can also remove this gate, so for an authoritative deny use a separate LemonSqueezy account whose API key the agent never sees.
273
+
274
+ Both are opt-in; with neither set, behavior is unchanged from prior versions.
275
+
276
+ ## Resources
277
+
278
+ The server exposes one MCP Resource for clients that prefer structural retrieval over parsing stderr:
279
+
280
+ | URI | MIME type | Contents |
281
+ | --- | --- | --- |
282
+ | `lemonsqueezy://audit-log` | `application/x-ndjson` | The most recent destructive tool calls and outcomes (rate-limit blocks, refund-cap blocks, exceptions, successes). Bounded ring buffer of the last 1000 entries, most-recent-first, resets on server restart. Redaction runs on the input payload before it reaches the buffer: any object key whose name matches a credential / PII pattern (`secret`, `password`, `token`, `api_key`, `bearer`, `authorization`, `signing_secret`, `private_key`, `pin`, `ssn`, `social_security_number`, `credit_card`, `card_number`, `cvv`, `cvc` — case-insensitive, whole-word) AND any string value matching the JWT bearer-token shape (`eyJ…`-prefixed, three base64url segments) is replaced with `[REDACTED]`. Ordinary identifiers (`licenseKey`, `instanceId`, `storeId`, `orderId`, `webhookId`) and UUID-shaped values are preserved. |
283
+
284
+ ## Operating the server unattended
285
+
286
+ For unattended/agentic use against a live store, we recommend:
287
+
288
+ 1. Issue an API key under a LemonSqueezy account that hosts only the store(s) the agent is allowed to touch — LemonSqueezy doesn't expose per-store API-key scoping, so account separation is the durable store boundary. Set `LEMONSQUEEZY_ALLOWED_STORE_IDS` to the same set as a belt-and-braces in-process gate on the tools that take a `storeId`.
289
+ 2. Set `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` to a per-call cap well below any single-refund expectation.
290
+ 3. Set `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` to a small number (e.g. 5/min) as a runaway-agent circuit breaker. For finer control, add `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS=money:2/h,recurring:5/h,key:10/m` so each [authority class](#authority-classes) has its own ceiling.
291
+ 4. If a class shouldn't be reachable at all (e.g. an analytics agent that needs only `read`), set `LEMONSQUEEZY_DISABLE_CLASSES` to the classes you want refused. The server rejects them before the API call is built. For an irrevocable deny, also issue the agent's API key from a separate LemonSqueezy account.
292
+ 5. Set `LEMONSQUEEZY_LOG=audit` and ship stderr to your log aggregator. The `audit` level keeps every destructive-call entry plus errors but drops successful reads so log volume stays bounded over weeks of operation. Alert on `status: "guardrail_block"` or elevated error rates per tool. Use `LEMONSQUEEZY_LOG=all` while debugging.
293
+ 6. Run `LEMONSQUEEZY_API_KEY_COMMAND` against a vault-backed secret so credentials can rotate without restarting the server process. The API client invalidates its in-process key cache automatically on a 401/403, so a rotated upstream key picks up on the next request rather than waiting on the 1h TTL.
294
+
295
+ What the server does **not** do and you must own at the caller level:
296
+
297
+ - **Idempotency / dedupe store** — MCP servers are stateless subprocesses; cross-invocation dedupe belongs in your agent or orchestrator.
298
+ - **Webhook reconciliation** — subscribe to LemonSqueezy webhooks in a separate long-running process to reconcile state when API writes succeed but the response is lost. See [@yawlabs/lemonsqueezy-webhook-sink](https://github.com/YawLabs/lemonsqueezy-webhook-sink) for a ready-made sink.
299
+ - **Metrics / dashboards** — the server emits structured logs; derive metrics in your log pipeline.
300
+
301
+ See [SEMVER.md](./SEMVER.md) for the versioning policy.
302
+
303
+ ## Development
304
+
305
+ ```bash
306
+ npm install
307
+ npm run lint
308
+ npm test # full unit + handler suite
309
+ npm run test:integration # requires LEMONSQUEEZY_TEST_API_KEY + LEMONSQUEEZY_TEST_STORE_ID
310
+ ```
311
+
312
+ `Containerfile` is generated from `Dockerfile`. After editing `Dockerfile`:
313
+
314
+ ```bash
315
+ npm run gen:containerfile # regenerate Containerfile
316
+ npm run check:containerfile # CI runs this; non-zero exit means the two have drifted
317
+ ```
318
+
319
+ ## Running on oam.js (optional)
320
+
321
+ [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 64 tools, the `lemonsqueezy://audit-log` resource, working `fetch`, and guardrail rejections with error text identical to Node.
322
+
323
+ **oam 0.9.0 is the minimum.** Older releases ran `child_process.execFile` arguments through a shell, which was reachable here whenever `LEMONSQUEEZY_API_KEY_COMMAND` is configured -- that feature shells out to fetch the key, and its arguments were re-split by a shell. The launcher enforces the floor: given an older oam it falls back to Node and says so on stderr, and `LEMONSQUEEZY_MCP_RUNTIME=oam` turns that into a hard error.
324
+
325
+ ### Sandboxing (opt-in)
326
+
327
+ Set `LEMONSQUEEZY_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.lemonsqueezy.com` (plus the host of `LEMONSQUEEZY_SINK_URL` when set), filesystem denied outright, and child-process denied unless `LEMONSQUEEZY_API_KEY_COMMAND` is configured.
328
+
329
+ It is opt-in rather than default because a wrong grant does not fail loudly. oam denies a non-granted environment variable by making it **absent** from `process.env` rather than throwing, so an under-granted `LEMONSQUEEZY_API_KEY` reads as "unauthenticated" rather than "denied". The env allow-list in the launcher is derived from what the shipped bundle actually reads -- if you add a new `process.env` lookup, extend that list with it.
330
+
331
+ ```jsonc
332
+ {
333
+ "mcpServers": {
334
+ "lemonsqueezy": {
335
+ "command": "oam",
336
+ "args": ["run", "/path/to/lemonsqueezy-mcp/dist/index.js"],
337
+ "env": { "LEMONSQUEEZY_API_KEY": "..." }
338
+ }
339
+ }
340
+ }
341
+ ```
342
+
343
+ Note the `--` separator if you pass arguments to the server rather than to oam: `oam run dist/index.js -- version`.
344
+
345
+ **Node stays the default, deliberately.** An MCP client cold-starts this server once per session, so startup is the cost that actually gets paid — and on the machine this was measured on, Node won: **196ms median against 424ms for `oam run`** (8 runs each, `version` subcommand, which exercises full boot plus tool registration). Making oam the default would mean either a launcher that probes for it on every start — a cost paid by everyone, including the majority who do not have oam installed — or making oam a hard requirement, which would break `npx @yawlabs/lemonsqueezy-mcp` for every user without it, since oam is not distributed on npm. Neither is worth it to reach a runtime that is slower here. Measure on your own hardware before concluding anything; if oam wins on yours, the config above is all you need.
346
+
347
+ Two places oam *does* win for this repo, both opt-in and neither touching the npm package:
348
+
349
+ - **`npm run check:oam`** — type-checks via `oam check` (tsgo, TypeScript 7 native). Measured 2878ms against 4406ms for `tsc --noEmit`, same clean result. `npx tsc --noEmit` remains the portable default and is what the pre-commit checklist calls for.
350
+ - **`npm run build:binary:oam`** — builds the standalone binary via `oam compile` instead of the Node SEA path. Measured 57.14 MB. Writes to the same `bin/<platform>-<arch>/` path as `npm run build:binary`, so the release staging script consumes either unchanged — run one or the other, not both. If you redistribute that binary it embeds oam's runtime, so ship oam's `LICENSE`, `NOTICE` and `THIRD_PARTY_LICENSES.md` with it.
351
+
352
+ The source stays runtime-agnostic on purpose: no `oam:` imports anywhere, and tests stay on `node:test`. That is what keeps the Node fallback real rather than nominal — an `oam:test` or `oam:`-prefixed import would make "falls back to Node" false the moment it landed. Any `oam` invocation writes a bytecode cache to `oam/` in the working directory; that path is gitignored.
353
+
354
+ ## Releasing
355
+
356
+ Two paths from a clean checkout of `main`. Both produce the same artifact (npm publish with provenance + GitHub release).
357
+
358
+ ### 1. Tag-and-let-CI (preferred)
359
+
360
+ ```bash
361
+ # 1. Bump version
362
+ npm version X.Y.Z --no-git-tag-version
363
+
364
+ # 2. Commit
365
+ git add package.json && git commit -m "vX.Y.Z"
366
+
367
+ # 3. Annotated tag (lightweight tags are silently skipped by --follow-tags)
368
+ git tag -a vX.Y.Z -m "vX.Y.Z"
369
+
370
+ # 4. Push commit + tag
371
+ git push origin main --follow-tags
372
+
373
+ # 5. Confirm the Release workflow fired (not just CI on the bump commit)
374
+ gh run list --limit 2
375
+ ```
376
+
377
+ The tag push triggers `.github/workflows/release.yml`, which runs `release.sh` in CI mode: lint, test, build, npm publish (with `--provenance`) using the org-level `NPM_TOKEN` secret, then GitHub release creation, then a smoke test against the published tarball, then a publish to the [Official MCP Registry](https://registry.modelcontextprotocol.io) via GitHub OIDC (no `MCP_*` secret needed; the namespace `io.github.YawLabs/*` is authorized purely from the OIDC `repository_owner` claim). No local `npm login` needed.
378
+
379
+ ### 2. Local end-to-end
380
+
381
+ ```bash
382
+ ./release.sh X.Y.Z
383
+ ```
384
+
385
+ Does the same steps 1–7 on the workstation: lint, test, build, bump, commit, annotated tag, push, npm publish, GitHub release, verify. Idempotent — safe to re-run with the same version after a partial failure. Requires one-time setup:
386
+
387
+ ```bash
388
+ npm login --auth-type=web # publisher of @yawlabs/lemonsqueezy-mcp
389
+ gh auth login # GitHub CLI for the release-creation step
390
+ ```
391
+
392
+ The local path does **not** publish to the Official MCP Registry — that step lives only in CI and depends on a GitHub Actions OIDC token. To push a locally-released version to the registry, install [`mcp-publisher`](https://github.com/modelcontextprotocol/registry/releases), run `mcp-publisher login github` (interactive OAuth), then `mcp-publisher publish` from the repo root.
393
+
394
+ ## License
395
+
396
+ MIT
package/dist/index.js CHANGED
@@ -23793,7 +23793,7 @@ function readAuditLogResource(uri) {
23793
23793
  }
23794
23794
 
23795
23795
  // src/index.ts
23796
- var version2 = true ? "0.13.2" : createRequire(import.meta.url)("../package.json").version;
23796
+ var version2 = true ? "0.13.3" : createRequire(import.meta.url)("../package.json").version;
23797
23797
  var subcommand = process.argv[2];
23798
23798
  if (subcommand === "version" || subcommand === "--version") {
23799
23799
  console.log(version2);
package/package.json CHANGED
@@ -1,61 +1,72 @@
1
- {
2
- "name": "@yawlabs/lemonsqueezy-mcp",
3
- "version": "0.13.2",
4
- "mcpName": "io.github.YawLabs/lemonsqueezy-mcp",
5
- "description": "LemonSqueezy MCP server for managing your store from AI assistants",
6
- "license": "MIT",
7
- "author": "YawLabs <contact@yaw.sh>",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/YawLabs/lemonsqueezy-mcp.git"
11
- },
12
- "keywords": [
13
- "lemonsqueezy",
14
- "lemon-squeezy",
15
- "mcp",
16
- "model-context-protocol",
17
- "ai",
18
- "payments",
19
- "subscriptions",
20
- "licensing"
21
- ],
22
- "type": "module",
23
- "main": "dist/index.js",
24
- "bin": {
25
- "lemonsqueezy-mcp": "bin/lemonsqueezy-mcp.mjs"
26
- },
27
- "files": [
28
- "bin/lemonsqueezy-mcp.mjs",
29
- "dist/index.js",
30
- "CHANGELOG.md"
31
- ],
32
- "scripts": {
33
- "build": "tsc && node build.mjs",
34
- "dev": "tsc --watch",
35
- "start": "node dist/index.js",
36
- "test": "npm run build && node --test-timeout=300000 --test \"dist/**/*.test.js\"",
37
- "test:integration": "npm run build && node --test-timeout=300000 --test dist/integration/*.test.js",
38
- "lint": "biome check src/",
39
- "lint:fix": "biome check --write src/",
40
- "check:oam": "oam check",
41
- "build:binary:oam": "node scripts/build-binary.mjs --oam",
42
- "gen:containerfile": "node scripts/sync-containerfile.mjs",
43
- "check:containerfile": "node scripts/sync-containerfile.mjs --check",
44
- "prepublishOnly": "npm run build"
45
- },
46
- "overrides": {
47
- "hono": "^4.12.14"
48
- },
49
- "devDependencies": {
50
- "@biomejs/biome": "^2.4.12",
51
- "@modelcontextprotocol/sdk": "^1.29.0",
52
- "@types/node": "^26.1.1",
53
- "esbuild": "^0.28.1",
54
- "postject": "^1.0.0-alpha.6",
55
- "typescript": "^7.0.2",
56
- "zod": "^4.3.6"
57
- },
58
- "engines": {
59
- "node": ">=22"
60
- }
61
- }
1
+ {
2
+ "name": "@yawlabs/lemonsqueezy-mcp",
3
+ "version": "0.13.3",
4
+ "mcpName": "io.github.YawLabs/lemonsqueezy-mcp",
5
+ "description": "Lemon Squeezy MCP server: stores, orders, subscriptions, license keys, refunds, and webhooks.",
6
+ "license": "MIT",
7
+ "author": "YawLabs <contact@yaw.sh>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/YawLabs/lemonsqueezy-mcp.git"
11
+ },
12
+ "keywords": [
13
+ "lemonsqueezy",
14
+ "lemon-squeezy",
15
+ "mcp",
16
+ "mcp-server",
17
+ "model-context-protocol",
18
+ "ai",
19
+ "agent",
20
+ "claude",
21
+ "claude-code",
22
+ "claude-desktop",
23
+ "payments",
24
+ "subscriptions",
25
+ "refunds",
26
+ "license-keys",
27
+ "licensing",
28
+ "checkout",
29
+ "webhooks",
30
+ "billing"
31
+ ],
32
+ "type": "module",
33
+ "main": "dist/index.js",
34
+ "bin": {
35
+ "lemonsqueezy-mcp": "bin/lemonsqueezy-mcp.mjs"
36
+ },
37
+ "files": [
38
+ "bin/lemonsqueezy-mcp.mjs",
39
+ "dist/index.js",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsc && node build.mjs",
44
+ "dev": "tsc --watch",
45
+ "start": "node dist/index.js",
46
+ "test": "npm run build && node --test-timeout=300000 --test \"dist/**/*.test.js\"",
47
+ "test:integration": "npm run build && node --test-timeout=300000 --test dist/integration/*.test.js",
48
+ "lint": "node scripts/lint.mjs check src/",
49
+ "lint:fix": "node scripts/lint.mjs check --write src/",
50
+ "check:oam": "oam check",
51
+ "build:binary:oam": "node scripts/build-binary.mjs --oam",
52
+ "gen:containerfile": "node scripts/sync-containerfile.mjs",
53
+ "check:containerfile": "node scripts/sync-containerfile.mjs --check",
54
+ "prepublishOnly": "npm run build"
55
+ },
56
+ "overrides": {
57
+ "hono": "^4.12.14"
58
+ },
59
+ "devDependencies": {
60
+ "@biomejs/biome": "^2.4.12",
61
+ "@modelcontextprotocol/sdk": "^1.29.0",
62
+ "@types/node": "^26.1.1",
63
+ "esbuild": "^0.28.1",
64
+ "postject": "^1.0.0-alpha.6",
65
+ "typescript": "^7.0.2",
66
+ "zod": "^4.3.6"
67
+ },
68
+ "engines": {
69
+ "node": ">=22"
70
+ },
71
+ "homepage": "https://yaw.sh/mcp-servers/lemonsqueezy-mcp/"
72
+ }