@yawlabs/lemonsqueezy-mcp 0.14.1 → 1.0.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 +51 -1
- package/README.md +82 -63
- package/bin/lemonsqueezy-mcp.mjs +13 -11
- package/dist/index.js +631 -242
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.0.0] — 2026-09-18
|
|
6
|
+
|
|
7
|
+
**1.0.0 locks the public surface.** [SEMVER.md](./SEMVER.md) now spells out what that surface is: tool names and input fields, MCP annotations, the authority class names and each tool's class, which calls count as destructive, the documented env vars, the `lemonsqueezy://audit-log` resource, and the fields the server adds to responses. From here on its MAJOR / MINOR / PATCH rules apply strictly. `src/tools/tools.test.ts` pins the `destructiveHint: true` set, the `isDestructive`-predicate set, the `readOnlyHint: false` set and the full tool-to-class map, so a change to any of them fails `npm test` until it is made deliberately. The changes marked **Breaking** below were made now, before the lock, because each would need a major version afterwards. Tracked in #2.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Breaking: `ls_deactivate_license` is now destructive (`destructiveHint: true`)** (#40). It revokes an instance's access on every call, whatever the input. `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` now counts it (`0` blocks it). Its calls are tagged `audit: true`, logged at `LEMONSQUEEZY_LOG=audit` even when they succeed, and recorded in `lemonsqueezy://audit-log`, with the license key masked (see Security). MCP clients that gate destructive tools will now ask before calling it. It stays in class `key`. `ls_activate_license` (additive) and `ls_validate_license` (read-only) are unchanged.
|
|
12
|
+
- **Breaking: the four input-dependent update tools now declare `destructiveHint: true`**: `ls_update_customer`, `ls_update_subscription`, `ls_update_license_key` and `ls_update_webhook`. Each can archive, pause, switch plan, disable or rotate a secret, and MCP defines `false` as "only additive updates". They keep their `isDestructive` predicates, and the server still decides per call from those, so the destructive rate limit and the audit log behave exactly as before for these four. What changes is the static hint MCP clients see, so they may now prompt for confirmation on these tools.
|
|
13
|
+
- **Breaking: `ls_create_usage_record` moves from authority class `mutate` to `recurring`.** Usage reports feed the metered bill, and `mutate` is documented as non-money, while `recurring` already holds the seat-quantity sibling `ls_update_subscription_item`. `LEMONSQUEEZY_DISABLE_CLASSES` lists that include `recurring` now block usage reporting, and lists with only `mutate` no longer do. `recurring:N` budgets in `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` now count usage reports, so raise yours if an automated metering agent shares it.
|
|
14
|
+
- **`ls_update_subscription` counts more calls as destructive:** any `billingAnchor`, `invoiceImmediately: true`, or any `trialEndsAt` (a date or `null`), on top of the existing pause (`void` / `free`) and plan switch (`variantId`). LemonSqueezy charges immediately on `invoice_immediately`, and a new billing anchor bills a prorated amount, so these are not "billing-neutral" as the old description said. `pause: "resume"`, `cancelled: false`, `invoiceImmediately: false` and `disableProrations` stay non-destructive.
|
|
15
|
+
- **`ls_update_license_key` counts any `expiresAt` change as destructive**, as it already did for any `activationLimit` change: the server cannot tell a shortened expiry from an extended one without a fetch. `disabled: true` still counts; `disabled: false` alone does not.
|
|
16
|
+
- **The Docker image now builds on `node:22-alpine`** (was `node:20-alpine`, past end of life and below `engines.node >=22`). `Containerfile` is regenerated to match.
|
|
17
|
+
- **`npm run test:integration` exits 1 when `LEMONSQUEEZY_TEST_API_KEY` or `LEMONSQUEEZY_TEST_STORE_ID` is unset.** Before, it skipped every suite and exited 0, so a missing credential looked like a pass. `npm test` still skips the integration suites quietly.
|
|
18
|
+
- **`release.sh` checks `npm whoami` before it starts** whenever the workstation will publish, so a dead npm token stops the release before the `v*` tag is pushed instead of after. It also no longer describes a CI release workflow; this repo has none, and the workstation run is the only release path.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `ls_update_license_key` accepts `activationLimit: null`, which LemonSqueezy documents as "unlimited". The old description said `0` meant unlimited, which is wrong; the field now says "Pass null for unlimited".
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **`ls_get_checkout` accepts real checkout IDs.** LemonSqueezy checkout IDs are UUIDs, the one management-API resource that does not use integer IDs, but `checkoutId` was validated as a positive-integer string, so every real checkout was rejected before the request was sent. A read-only probe against a live store caught it; the mocked unit suite could not. An integer `checkoutId` is now rejected locally, where before it passed validation and could only ever 404.
|
|
27
|
+
|
|
28
|
+
### Security
|
|
29
|
+
|
|
30
|
+
- **License keys are masked in audit and log output.** An input key named `licenseKey`, `license_key` or `license-key` (any case) is written as `[REDACTED:last4=XXXX]` when its value is a string of 16 or more characters, and as `[REDACTED]` otherwise. `licenseKeyId`, `license_key_id` and `licenseKeyInstanceId` are left alone. The License API authenticates with the key alone, so it is a bearer credential. This is what lets `ls_deactivate_license` be audited without writing live keys to stderr or the audit resource. Masking goes by key name only, so a key under an arbitrary field such as `customData` is not caught.
|
|
31
|
+
- **An upstream error body with no usable message is logged and returned redacted.** When a JSON error body carries no string `errors[0].detail`, `errors[0].title` or `error`, the whole body is surfaced through the same redactor as audited inputs, rather than raw. A License API error body can embed the caller's key under `license_key.key`. A non-string `error` / `detail` / `title` is no longer taken as the message either, so an object nesting the key cannot skip the redactor.
|
|
32
|
+
|
|
33
|
+
### Documentation
|
|
34
|
+
|
|
35
|
+
- README: the tool catalogue lists all 64 tools, adding the Affiliates section (#42). The release section documents `./release.sh` as the only path (steps 1-8, including the npx smoke test and the MCP Registry publish), with no provenance on a workstation publish and an explicit warning never to run `npm login --auth-type=web` (#43). The `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` row now lists every destructive call. Also corrected: the retry behaviour, the `LEMONSQUEEZY_API_KEY_COMMAND` tokenizer (no shell), what `pii` actually covers (only the dedicated customer tools; read-class tools still return customer names and emails and accept `include=customer`), the License API tools' boundary (neither the store allowlist nor account separation reaches them), and the redaction rules.
|
|
36
|
+
- SEMVER.md is rewritten for 1.0: the covered surface, and MAJOR / MINOR / PATCH rules for annotations, authority classes, destructive coverage, redaction and the Node floor.
|
|
37
|
+
- CONTRIBUTING.md, `smithery.yaml` and CLAUDE.md are brought in line with the above.
|
|
38
|
+
|
|
39
|
+
### Build
|
|
40
|
+
|
|
41
|
+
- `.gitattributes` forces LF line endings, so a Windows checkout passes Biome and the release gate (#55). The `typeRoots` workaround for `oam check` is reverted, now that oam 0.15.3 fixes the check (#56). Neither changes runtime behaviour.
|
|
42
|
+
|
|
43
|
+
## [0.14.2] — 2026-09-14
|
|
44
|
+
|
|
45
|
+
### Security
|
|
46
|
+
|
|
47
|
+
- **The `@modelcontextprotocol/sdk` devDependency moves from `^1.29.0` to `^1.30.0`, and `npm audit` is clean again (it reported 6 findings, 2 high).** The SDK's transitive dependencies all move past their advisories: `fast-uri` 3.1.2 -> 3.1.7, `hono` 4.12.26 -> 4.13.7 (the existing `hono` override floor goes from `^4.12.14` to `^4.13.5`, the first patched version), `@hono/node-server` 1.19.13 -> 2.1.1, `ip-address` 10.2.0 -> 10.7.0, `qs` 6.15.2 -> 6.16.0 and `body-parser` 2.2.2 -> 2.3.0. Only `fast-uri` (via `ajv`) is bundled into the published `dist/index.js`, and the 0.14.1 bundle carried 3.1.2, inside the advised range; the others never reach the bundle, because the server uses only the SDK's stdio transport.
|
|
48
|
+
|
|
5
49
|
## [0.14.1] — 2026-09-14
|
|
6
50
|
|
|
7
51
|
### Changed
|
|
@@ -267,6 +311,8 @@ All notable changes to `@yawlabs/lemonsqueezy-mcp` are documented here. The form
|
|
|
267
311
|
|
|
268
312
|
## [0.10.9] -- 2026-05-22
|
|
269
313
|
|
|
314
|
+
Tagged (`v0.10.9`) but never published to npm and never given a GitHub release; these changes first shipped in 0.10.10.
|
|
315
|
+
|
|
270
316
|
### Changed
|
|
271
317
|
|
|
272
318
|
- **Webhook `events` array now requires at least one entry.** `ls_create_webhook` and `ls_update_webhook` previously let an empty array pass local Zod validation, so a no-op webhook configuration only failed at the LemonSqueezy API as a 422. The new `.min(1)` makes the rejection local with a clearer message; on update, the field stays optional but, when supplied, must be non-empty.
|
|
@@ -428,6 +474,8 @@ All notable changes to `@yawlabs/lemonsqueezy-mcp` are documented here. The form
|
|
|
428
474
|
|
|
429
475
|
## [0.8.0] -- 2026-05-13
|
|
430
476
|
|
|
477
|
+
Never tagged and never published to npm (the version bump is commit `0f010e1`); these changes first shipped in 0.8.1.
|
|
478
|
+
|
|
431
479
|
Distribution-readiness pass -- closes feature gaps against other OSS LemonSqueezy MCP servers ahead of broader distribution.
|
|
432
480
|
|
|
433
481
|
### Added
|
|
@@ -628,7 +676,9 @@ Hardening pass for unattended automation against live billing flows.
|
|
|
628
676
|
|
|
629
677
|
Initial release. 59 tools covering all 17 LemonSqueezy API resources.
|
|
630
678
|
|
|
631
|
-
[Unreleased]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/
|
|
679
|
+
[Unreleased]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v1.0.0...HEAD
|
|
680
|
+
[1.0.0]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.14.2...v1.0.0
|
|
681
|
+
[0.14.2]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.14.1...v0.14.2
|
|
632
682
|
[0.14.1]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.14.0...v0.14.1
|
|
633
683
|
[0.14.0]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.13.5...v0.14.0
|
|
634
684
|
[0.13.5]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.13.4...v0.13.5
|
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ One click adds this to your local Yaw MCP config so it's available in every Yaw
|
|
|
12
12
|
npx -y @yawlabs/lemonsqueezy-mcp@latest
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
Requires Node.js 22 or later (`engines.node` is `>=22`).
|
|
16
|
+
|
|
15
17
|
Or one-click install via Smithery:
|
|
16
18
|
|
|
17
19
|
```bash
|
|
@@ -55,14 +57,14 @@ Get your API key from your [LemonSqueezy dashboard](https://app.lemonsqueezy.com
|
|
|
55
57
|
|
|
56
58
|
### Docker
|
|
57
59
|
|
|
58
|
-
A multi-stage `Dockerfile` is included at the repo root. The runtime image is a single bundled file on `node:
|
|
60
|
+
A multi-stage `Dockerfile` is included at the repo root. The runtime image is a single bundled file on `node:22-alpine` running as the non-root `node` user, with no port exposed (stdio transport).
|
|
59
61
|
|
|
60
62
|
```bash
|
|
61
63
|
docker build -t yawlabs/lemonsqueezy-mcp .
|
|
62
64
|
docker run --rm -i -e LEMONSQUEEZY_API_KEY="your-api-key" yawlabs/lemonsqueezy-mcp
|
|
63
65
|
```
|
|
64
66
|
|
|
65
|
-
A matching `Containerfile` is provided for Podman users. It is generated from `Dockerfile` via `npm run gen:containerfile
|
|
67
|
+
A matching `Containerfile` is provided for Podman users. It is generated from `Dockerfile` via `npm run gen:containerfile`. `release.sh` runs `npm run check:containerfile` in step 1, so a release fails if the two have drifted. This repo has no CI, so nothing checks a pull request for drift: run it yourself after editing `Dockerfile`.
|
|
66
68
|
|
|
67
69
|
### Claude Code
|
|
68
70
|
|
|
@@ -107,7 +109,7 @@ Add to `claude_desktop_config.json`:
|
|
|
107
109
|
|
|
108
110
|
### Stores
|
|
109
111
|
- `ls_get_store` — Get a store by ID
|
|
110
|
-
- `ls_list_stores` — List all stores
|
|
112
|
+
- `ls_list_stores` — List all stores (not gated by `LEMONSQUEEZY_ALLOWED_STORE_IDS`: returns every store the API key can see)
|
|
111
113
|
|
|
112
114
|
### Customers
|
|
113
115
|
- `ls_get_customer` — Get a customer by ID
|
|
@@ -128,7 +130,7 @@ Add to `claude_desktop_config.json`:
|
|
|
128
130
|
- `ls_get_price` — Get a price by ID
|
|
129
131
|
- `ls_list_prices` — List prices (filter by variant)
|
|
130
132
|
|
|
131
|
-
Both annotate every record with `effective_unit_price`, the cents actually charged per unit. Read
|
|
133
|
+
Both annotate every price record with `effective_unit_price`, the cents actually charged per unit, and `effective_unit_price_note`, which says where that number came from; `unit_price_is_not_charged` or `unit_price_is_per_package` is added where it applies. Read `effective_unit_price` rather than `unit_price`, which is vestigial on tiered (`volume` / `graduated`) pricing, or a variant's `price`. The subscription-item reads apply the same annotation to prices embedded via `include=price`.
|
|
132
134
|
|
|
133
135
|
### Files
|
|
134
136
|
- `ls_get_file` — Get a file by ID
|
|
@@ -201,7 +203,11 @@ Both annotate every record with `effective_unit_price`, the cents actually charg
|
|
|
201
203
|
### License API
|
|
202
204
|
- `ls_activate_license` — Activate a license key (no API key required)
|
|
203
205
|
- `ls_validate_license` — Validate a license key (no API key required)
|
|
204
|
-
- `ls_deactivate_license` — Deactivate a license key instance (no API key required)
|
|
206
|
+
- `ls_deactivate_license` — Deactivate a license key instance (no API key required). Destructive on every call: rate-limited and audited, with the license key masked in the audit entry
|
|
207
|
+
|
|
208
|
+
### Affiliates
|
|
209
|
+
- `ls_get_affiliate` — Get an affiliate by ID (commission rate, status, earnings)
|
|
210
|
+
- `ls_list_affiliates` — List affiliates (filter by user email). Not gated by `LEMONSQUEEZY_ALLOWED_STORE_IDS`: returns affiliates from every store the API key can see
|
|
205
211
|
|
|
206
212
|
### Webhook sink (optional)
|
|
207
213
|
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.
|
|
@@ -212,31 +218,31 @@ Bridge to a separate [@yawlabs/lemonsqueezy-webhook-sink](https://github.com/Yaw
|
|
|
212
218
|
|
|
213
219
|
## Features
|
|
214
220
|
|
|
215
|
-
- **
|
|
216
|
-
- **JSON:API support** —
|
|
221
|
+
- **API coverage** — 58 tools across 20 LemonSqueezy API resources, 3 License API tools, and 3 bridge tools to an optional [@yawlabs/lemonsqueezy-webhook-sink](https://github.com/YawLabs/lemonsqueezy-webhook-sink) for webhook reconciliation (64 in total)
|
|
222
|
+
- **JSON:API support** — Pagination on every `ls_list_*` tool, filters on every one but `ls_list_stores`, and relationship inclusion (`include`) on every `ls_get_*` / `ls_list_*` tool except `ls_get_user` and `ls_get_subscription_item_usage`
|
|
217
223
|
- **Zero runtime dependencies** — Single bundled file for instant `npx` startup
|
|
218
224
|
- **License API** — Activate, validate, and deactivate license keys without an API key
|
|
219
225
|
- **MCP annotations** — Every tool declares read-only, destructive, and idempotent hints
|
|
220
|
-
- **Retry with backoff** — 429 and 5xx
|
|
226
|
+
- **Retry with backoff** — for LemonSqueezy API calls (the `ls_sink_*` tools make a single attempt): a 429 is retried for every method, including POSTs such as refunds and License API calls, after waiting for the server's `Retry-After` (1s if it is missing or unparseable). This relies on a 429 meaning the request was rejected before LemonSqueezy acted on it. If `Retry-After` is over 30s, or the wait would pass the overall deadline, the 429 is returned instead. 5xx responses, timeouts and network errors are retried only for idempotent methods (GET, DELETE), with exponential backoff and jitter; a POST or PATCH that hits one is not retried. Up to 4 attempts, 30s per attempt, and no new attempt or wait is started after 90s
|
|
221
227
|
- **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`)
|
|
222
228
|
- **Audit log MCP Resource** — `lemonsqueezy://audit-log` exposes the last 1000 destructive-call entries as `application/x-ndjson` for clients without stderr access
|
|
223
229
|
- **Structured logging** — opt-in JSON logs to stderr with selectable levels (`error`, `audit`, `all`)
|
|
224
230
|
|
|
225
231
|
## Configuration
|
|
226
232
|
|
|
227
|
-
All configuration is via environment variables.
|
|
233
|
+
All configuration is via environment variables. The management-API tools need one of `LEMONSQUEEZY_API_KEY_COMMAND`, `LEMONSQUEEZY_TEST_API_KEY` or `LEMONSQUEEZY_API_KEY` (checked in that order); the three License API tools and the `ls_sink_*` tools need none of them. Everything else is opt-in.
|
|
228
234
|
|
|
229
235
|
| Variable | Purpose |
|
|
230
236
|
| --- | --- |
|
|
231
237
|
| `LEMONSQUEEZY_API_KEY` | LemonSqueezy API token. |
|
|
232
|
-
| `LEMONSQUEEZY_API_KEY_COMMAND` | Command whose stdout
|
|
238
|
+
| `LEMONSQUEEZY_API_KEY_COMMAND` | Command whose stdout (trimmed, must be non-empty) is the API key. Takes precedence over `LEMONSQUEEZY_TEST_API_KEY` and `LEMONSQUEEZY_API_KEY`. Use this to pull short-lived credentials from a vault (`op read`, `gcloud secrets versions access`, etc.) without writing them to env vars. It is run directly, **not through a shell**: the string is split on spaces and tabs, single or double quotes group an argument (with no escapes inside them), and a backslash is a literal character. Adjacent quoted and unquoted text joins into one argument, and an empty `''` is dropped. There is no `$VAR` / `~` expansion, globbing, command substitution, piping or redirection. If you need any of those, put them in a script and invoke the script through its interpreter, e.g. `sh /path/get-key.sh`, `node get-key.js`, or on Windows `powershell -NoProfile -File C:\path\get-key.ps1`. **On Windows, a `.cmd` / `.bat` file cannot be launched directly, by bare name or by full path**, and many vault CLIs install as `.cmd` shims (e.g. `gcloud`): wrap them as `cmd /c gcloud secrets versions access ...`, or call an `.exe` such as `op`. The command gets 10 seconds, and stdout and stderr are each capped at 64 KB. Output is cached for 1 hour. 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. |
|
|
233
239
|
| `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. |
|
|
234
|
-
| `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
|
|
235
|
-
| `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` | Non-negative
|
|
236
|
-
| `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` | Non-negative
|
|
237
|
-
| `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`
|
|
238
|
-
| `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. |
|
|
239
|
-
| `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),
|
|
240
|
+
| `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 three distinct shapes: (a) tools addressed by a resource ID (e.g. `ls_get_order`, `ls_refund_order`, `ls_cancel_subscription`, `ls_update_subscription_item`, `ls_create_usage_record`, `ls_archive_customer`, `ls_delete_webhook`, `ls_delete_discount`, `ls_update_license_key`) route by that ID, so the caller must already know it; (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. (c) The License API tools (`ls_activate_license`, `ls_validate_license`, `ls_deactivate_license`) authenticate with the license key the caller passes, not the API key, so neither this allowlist nor account separation reaches them; see [Authority classes](#authority-classes) for the controls that do. 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 for the tools that use the API key. 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. |
|
|
241
|
+
| `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` | Non-negative number. Rejects `ls_refund_order` and `ls_refund_subscription_invoice` calls above this amount (refund amounts are whole cents, so a fractional cap such as `99.5` allows up to 99). 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. A negative or non-numeric value stops the server at startup. The check runs *before* the rate limiters, so a rejected over-cap refund does not consume your destructive or `money`-class budget. |
|
|
242
|
+
| `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` | Non-negative number. Max destructive tool calls per 60-second rolling window (a fraction behaves as the next whole number: `2.5` allows 3 calls). Unset or empty means no limit; **`0` blocks every destructive call.** A negative or non-numeric value stops the server at startup. In-process limit — per MCP server instance, not global; each `npx` cold start resets the window. **Destructive on every call** (whatever the input): `ls_refund_order`, `ls_refund_subscription_invoice`, `ls_cancel_subscription`, `ls_update_subscription_item`, `ls_create_usage_record`, `ls_archive_customer`, `ls_delete_discount`, `ls_delete_webhook` and `ls_deactivate_license`. **Destructive depending on input:** `ls_update_license_key` when it sets `disabled: true` or passes any `activationLimit` or `expiresAt` (`null` included); `ls_update_subscription` when it pauses (`pause: "void"` or `"free"`), switches plan (`variantId`), passes `billingAnchor`, passes `trialEndsAt` (a date or `null`), or sets `invoiceImmediately: true`; `ls_update_customer` with `status: "archived"`; and `ls_update_webhook` when it passes `secret`. No other tool call is destructive. A destructive call refused first by `LEMONSQUEEZY_DISABLE_CLASSES`, the refund cap or `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` never reaches this counter, so it is not counted. Every destructive call, including a blocked one, is tagged `audit: true` in the log and recorded in `lemonsqueezy://audit-log`. MCP clients see `destructiveHint: true` on all 13 of these tools, including the four input-dependent ones, because an annotation is static; the server-side count for those four is per call. |
|
|
243
|
+
| `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` blocks refunds, subscription changes and usage reporting, and the dedicated customer tools, while leaving the `read`, `mutate`, `key` and `webhook` classes open. Disabling `pii` does not keep customer data away from the agent: read-class tools still return customer names and emails (see the note under [Authority classes](#authority-classes)). Unknown class names stop the server at startup. |
|
|
244
|
+
| `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`. A `recurring` budget counts usage reports (`ls_create_usage_record`) alongside subscription changes, so size it for any metering agent. 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. |
|
|
245
|
+
| `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 (or any other value): no logs. Destructive calls are tagged `audit: true` and include their inputs, redacted as described under [Resources](#resources) (the stderr log and the audit-log resource apply the same redaction). Failure entries carry a `status` that identifies the cause. On `tool_call` lines: `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), or `error` (the tool returned an error result, such as an HTTP error from LemonSqueezy or a timed-out request). On `http_call` lines: `timeout`, `network_error`, or the HTTP status code. |
|
|
240
246
|
| `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. |
|
|
241
247
|
| `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. |
|
|
242
248
|
|
|
@@ -246,34 +252,44 @@ Each line: `{ts, event, tool?, method?, path?, status, latency_ms, request_id?,
|
|
|
246
252
|
|
|
247
253
|
### Error decoration
|
|
248
254
|
|
|
249
|
-
HTTP errors include the upstream `X-Request-Id` when present, so support tickets to LemonSqueezy can reference the exact call.
|
|
255
|
+
HTTP errors include the upstream `X-Request-Id` when present, so support tickets to LemonSqueezy can reference the exact call. When an error body is JSON with no message the server recognizes, it is surfaced whole, in both the error text and the log line, after the same redaction as audited inputs (see [Resources](#resources)). A non-JSON error body is passed through as-is.
|
|
250
256
|
|
|
251
257
|
## Authority classes
|
|
252
258
|
|
|
253
|
-
**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*.
|
|
259
|
+
**The strongest access control LemonSqueezy itself exposes is the API key boundary.** A LemonSqueezy API key authorizes its full account — every store, and every tool except the three License API tools (see below) and the three `ls_sink_*` bridge tools, which do not use it — 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*.
|
|
254
260
|
|
|
255
|
-
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.
|
|
261
|
+
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. That answer does not cover the License API tools (see below).
|
|
256
262
|
|
|
257
263
|
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.
|
|
258
264
|
|
|
259
|
-
| Class | What it covers |
|
|
265
|
+
| Class | What it covers | Tools |
|
|
260
266
|
| --- | --- | --- |
|
|
261
|
-
| `read` | Safe reads (list/get) that don't return customer PII as the primary payload. | `
|
|
262
|
-
| `pii` |
|
|
263
|
-
| `mutate` |
|
|
267
|
+
| `read` | Safe reads (list/get) that don't return customer PII as the primary payload. | Every `ls_get_*` and `ls_list_*` tool except `ls_get_customer` and `ls_list_customers`, plus `ls_validate_license`, `ls_sink_events_list` and `ls_sink_stats` |
|
|
268
|
+
| `pii` | The dedicated customer-record tools. This class does not keep customer data out of reach; see the note below. | `ls_get_customer`, `ls_list_customers`, `ls_create_customer`, `ls_update_customer`, `ls_archive_customer` |
|
|
269
|
+
| `mutate` | Non-money mutations: checkouts, discounts, invoice generation, marking sink events processed. | `ls_create_checkout`, `ls_create_discount`, `ls_delete_discount`, `ls_generate_order_invoice`, `ls_generate_subscription_invoice`, `ls_sink_event_mark_processed` |
|
|
264
270
|
| `money` | Money movement. Irreversible at the payment layer. | `ls_refund_order`, `ls_refund_subscription_invoice` |
|
|
265
|
-
| `recurring` | Subscription state changes that affect recurring revenue. | `ls_update_subscription`, `ls_cancel_subscription`, `ls_update_subscription_item` |
|
|
266
|
-
| `key` | License-key admin (activate, deactivate, disable, change activation limit). | `ls_update_license_key`, `ls_activate_license`, `ls_deactivate_license` |
|
|
271
|
+
| `recurring` | Subscription state and metered billing: changes that affect recurring revenue. | `ls_update_subscription`, `ls_cancel_subscription`, `ls_update_subscription_item`, `ls_create_usage_record` |
|
|
272
|
+
| `key` | License-key admin (activate, deactivate, disable, change activation limit or expiry). | `ls_update_license_key`, `ls_activate_license`, `ls_deactivate_license` |
|
|
267
273
|
| `webhook` | Webhook configuration — affects the trust surface other systems rely on. | `ls_create_webhook`, `ls_update_webhook`, `ls_delete_webhook` |
|
|
268
274
|
|
|
269
|
-
|
|
275
|
+
That is every tool's class. `src/tools/tools.test.ts` pins the same map, and moving a tool to another class is a MAJOR change under [SEMVER.md](./SEMVER.md).
|
|
276
|
+
|
|
277
|
+
Note: `pii` gates only the five customer tools above. It does **not** keep customer data out of an agent's reach: read-class tools still return customer names and emails incidentally (orders, subscriptions and license keys carry them), `ls_list_orders` and `ls_list_subscriptions` accept a `userEmail` filter, and the order, subscription and license-key get/list tools accept `include=customer`, which side-loads the customer record. The class is reserved for tools whose *primary purpose* is the customer record, so `LEMONSQUEEZY_DISABLE_CLASSES=pii` blocks those tools, not customer data. To keep customer data away from an agent, issue its API key from a separate LemonSqueezy account that doesn't host customer records you care about.
|
|
278
|
+
|
|
279
|
+
**The License API tools are outside the API-key boundary.** `ls_activate_license`, `ls_validate_license` and `ls_deactivate_license` authenticate with the license key the caller passes, not the API key, so they work on any LemonSqueezy account's license keys, whichever API key the server holds. A separate LemonSqueezy account does not fence them off, and `LEMONSQUEEZY_ALLOWED_STORE_IDS` does not gate them. The controls that do reach them, per tool:
|
|
280
|
+
|
|
281
|
+
- **`ls_deactivate_license`** (class `key`): `LEMONSQUEEZY_DISABLE_CLASSES=key`, `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS=key:N`, and `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT`, which treats every call as destructive. Every call is also audited, with the license key masked (see [Resources](#resources)).
|
|
282
|
+
- **`ls_activate_license`** (class `key`): the same two class controls. Activation is additive, so it is not destructive, and the destructive rate limit and the audit log do not see it.
|
|
283
|
+
- **`ls_validate_license`** (class `read`): only `LEMONSQUEEZY_DISABLE_CLASSES=read` or `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS=read:N`, and both of those also hit every other read. The read-only lock-down below leaves it reachable.
|
|
284
|
+
|
|
285
|
+
Both `key` controls also cover `ls_update_license_key`, the management-API tool in the same class.
|
|
270
286
|
|
|
271
287
|
The two opt-in env vars that consume this taxonomy:
|
|
272
288
|
|
|
273
289
|
- `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.**
|
|
274
|
-
- `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.
|
|
290
|
+
- `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 (every `read` tool stays reachable, `ls_validate_license` included). 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; that does not bound the License API tools.
|
|
275
291
|
|
|
276
|
-
Both are opt-in; with neither set,
|
|
292
|
+
Both are opt-in; with neither set, every class is allowed and unlimited.
|
|
277
293
|
|
|
278
294
|
## Resources
|
|
279
295
|
|
|
@@ -281,16 +297,16 @@ The server exposes one MCP Resource for clients that prefer structural retrieval
|
|
|
281
297
|
|
|
282
298
|
| URI | MIME type | Contents |
|
|
283
299
|
| --- | --- | --- |
|
|
284
|
-
| `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.
|
|
300
|
+
| `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. Inputs are redacted before they reach the buffer, and the stderr log gets the same redacted copy. (1) Any object key whose whole 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, and the `_` in a compound name may also be `-` or absent) has its value replaced with `[REDACTED]`. (2) A key named `licenseKey`, `license_key` or `license-key` (any case) holds a License API credential, so its value is masked: a string of 16 or more characters becomes `[REDACTED:last4=XXXX]`, keeping its last four characters so an entry can be correlated with a key without exposing it, and any other value becomes `[REDACTED]`. (3) Any string value matching the JWT bearer-token shape (`eyJ…`-prefixed, three base64url segments) becomes `[REDACTED]` under whatever key it appears. Rules (1) and (2) go by key name, so a license key stored under some other key name is not masked. Values under any other key name are kept, including the IDs `licenseKeyId`, `license_key_id`, `licenseKeyInstanceId`, `instanceId`, `storeId`, `orderId` and `webhookId`, and UUID-shaped values. |
|
|
285
301
|
|
|
286
302
|
## Operating the server unattended
|
|
287
303
|
|
|
288
304
|
For unattended/agentic use against a live store, we recommend:
|
|
289
305
|
|
|
290
|
-
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`.
|
|
306
|
+
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`. Account separation does not bound the three License API tools, which authenticate with a license key rather than the API key (see [Authority classes](#authority-classes)).
|
|
291
307
|
2. Set `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` to a per-call cap well below any single-refund expectation.
|
|
292
|
-
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.
|
|
293
|
-
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.
|
|
308
|
+
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. If the agent also reports metered usage, size the `recurring` figure for that too: usage reports count against it.
|
|
309
|
+
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 (the License API tools excepted).
|
|
294
310
|
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.
|
|
295
311
|
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.
|
|
296
312
|
|
|
@@ -307,22 +323,24 @@ See [SEMVER.md](./SEMVER.md) for the versioning policy.
|
|
|
307
323
|
```bash
|
|
308
324
|
npm install
|
|
309
325
|
npm run lint
|
|
310
|
-
npm test # full unit + handler suite
|
|
311
|
-
npm run test:integration #
|
|
326
|
+
npm test # full unit + handler suite (builds first)
|
|
327
|
+
npm run test:integration # live suite; exits 1 unless LEMONSQUEEZY_TEST_API_KEY + LEMONSQUEEZY_TEST_STORE_ID are set
|
|
312
328
|
```
|
|
313
329
|
|
|
330
|
+
This repo has no CI: nothing re-runs lint, types or tests on a pull request, so run them locally. `npm run test:integration` writes throwaway `ci-test-` resources to a real store.
|
|
331
|
+
|
|
314
332
|
`Containerfile` is generated from `Dockerfile`. After editing `Dockerfile`:
|
|
315
333
|
|
|
316
334
|
```bash
|
|
317
335
|
npm run gen:containerfile # regenerate Containerfile
|
|
318
|
-
npm run check:containerfile #
|
|
336
|
+
npm run check:containerfile # release.sh step 1 runs this too; non-zero exit means the two have drifted
|
|
319
337
|
```
|
|
320
338
|
|
|
321
339
|
## Running on oam.js (optional)
|
|
322
340
|
|
|
323
|
-
[oam.js](https://oamjs.org) runs this server unmodified, and the launcher only
|
|
341
|
+
[oam.js](https://oamjs.org) runs this server unmodified, and the launcher only runs the server on an oam that is **0.15.2 or newer**. 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. On oam 0.15.2 the MCP handshake and all 64 tools have been re-verified through the launcher, with and without the sandbox below.
|
|
324
342
|
|
|
325
|
-
**oam 0.15.2 is the minimum.** The launcher picks the newest oam it can find at or above it, never serves on an older one, and falls back to Node when there is none (`LEMONSQUEEZY_MCP_RUNTIME=oam` turns that into a hard error). A floor matters here: releases before 0.9.0 ran `child_process.execFile` arguments through a shell, which was reachable whenever `LEMONSQUEEZY_API_KEY_COMMAND` is configured -- that feature
|
|
343
|
+
**oam 0.15.2 is the minimum.** The launcher picks the newest oam it can find at or above it, never serves on an older one, and falls back to Node when there is none (`LEMONSQUEEZY_MCP_RUNTIME=oam` turns that into a hard error). A floor matters here: releases before 0.9.0 ran `child_process.execFile` arguments through a shell, which was reachable whenever `LEMONSQUEEZY_API_KEY_COMMAND` is configured -- that feature runs the key command with `execFile`, deliberately without a shell, and on those releases its arguments were re-split by one anyway.
|
|
326
344
|
|
|
327
345
|
The published `lemonsqueezy-mcp` command (`bin/lemonsqueezy-mcp.mjs`, which is what `npx` runs) chooses its runtime from these variables:
|
|
328
346
|
|
|
@@ -359,49 +377,50 @@ Note the `--` separator if you pass arguments to the server rather than to oam:
|
|
|
359
377
|
Two places oam *does* win for this repo, both opt-in and neither touching the npm package:
|
|
360
378
|
|
|
361
379
|
- **`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.
|
|
362
|
-
- **`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
|
|
380
|
+
- **`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 the Node SEA build (`node scripts/build-binary.mjs`), so `scripts/stage-release-asset.mjs` 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.
|
|
363
381
|
|
|
364
382
|
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.
|
|
365
383
|
|
|
366
384
|
## Releasing
|
|
367
385
|
|
|
368
|
-
|
|
386
|
+
Releases run locally, from a clean checkout of `main`, with `release.sh`. This repo has no GitHub Actions workflows (`.github/` holds only `CODEOWNERS`), so pushing a tag by itself publishes nothing. `release.sh` keeps a CI mode for a tag-triggered release workflow, the intended end state, but nothing invokes it today.
|
|
369
387
|
|
|
370
|
-
|
|
388
|
+
Before cutting a release, write the `## [Unreleased]` entry in `CHANGELOG.md` by hand, and run `npm run test:integration` against a test-mode store if `src/api.ts` or a tool handler changed (it needs `LEMONSQUEEZY_TEST_API_KEY` and `LEMONSQUEEZY_TEST_STORE_ID`, and exits 1 without them). The unit suite mocks `fetch`, so it cannot see changes in the upstream API. Then:
|
|
371
389
|
|
|
372
390
|
```bash
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
# 2. Commit
|
|
377
|
-
git add package.json && git commit -m "vX.Y.Z"
|
|
391
|
+
./release.sh X.Y.Z
|
|
392
|
+
```
|
|
378
393
|
|
|
379
|
-
|
|
380
|
-
git tag -a vX.Y.Z -m "vX.Y.Z"
|
|
394
|
+
Before step 1, `release.sh` checks that `gh` is installed and authenticated and that you are on `main`, and refuses a dirty working tree unless it is resuming a run whose version bump already landed. Unless `X.Y.Z` is already on npm, it also runs `npm whoami` and stops if that fails, so a dead or missing npm token ends the run before step 4 pushes the tag. `npm whoami` proves the token is live, not that it is an automation token: a web-login session passes it and then fails step 5 with `EOTP`.
|
|
381
395
|
|
|
382
|
-
|
|
383
|
-
git push origin main --follow-tags
|
|
396
|
+
It then runs eight steps on the workstation:
|
|
384
397
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
398
|
+
1. `npm run lint`, and `npm run check:containerfile` (fails if `Containerfile` has drifted from `Dockerfile`)
|
|
399
|
+
2. `npm test` (which builds first)
|
|
400
|
+
3. Bump the version in `package.json` and `package-lock.json`, and sync `server.json` to it
|
|
401
|
+
4. Turn `## [Unreleased]` in `CHANGELOG.md` into `## [X.Y.Z]` (when `[Unreleased]` is empty, write that section from the commit subjects since the previous tag instead, with a warning), commit, create the annotated tag `vX.Y.Z`, and push `main` with the tag
|
|
402
|
+
5. `npm publish --access public`
|
|
403
|
+
6. Create the GitHub release, with the notes taken from the `## [X.Y.Z]` section of `CHANGELOG.md`
|
|
404
|
+
7. Wait for npm to serve the new version (up to 5 minutes, then carry on with a warning), smoke-test `npx -y @yawlabs/lemonsqueezy-mcp@X.Y.Z --version`, then publish `server.json` to the [Official MCP Registry](https://registry.modelcontextprotocol.io) with `mcp-publisher`
|
|
405
|
+
8. Verify the npm version, `package.json` and the tag
|
|
388
406
|
|
|
389
|
-
|
|
407
|
+
Re-running after a partial failure is safe. `./release.sh X.Y.Z` detects the version bump, commit, tag, npm publish and GitHub release that already landed and skips them; lint, test, the push and the npx smoke test run again. The MCP Registry publish in step 7 is not skipped, so a re-run after it has succeeded fails there on the duplicate version.
|
|
390
408
|
|
|
391
|
-
|
|
409
|
+
A workstation publish carries no npm provenance attestation: `release.sh` passes `--provenance` only in CI mode.
|
|
392
410
|
|
|
393
|
-
|
|
394
|
-
./release.sh X.Y.Z
|
|
395
|
-
```
|
|
411
|
+
One-time setup on each machine:
|
|
396
412
|
|
|
397
|
-
|
|
413
|
+
- **An npm automation token** for a publisher of `@yawlabs/lemonsqueezy-mcp`, in `~/.npmrc` (npmjs.com -> Access Tokens -> Generate -> Automation):
|
|
398
414
|
|
|
399
|
-
```
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
```
|
|
415
|
+
```ini
|
|
416
|
+
@yawlabs:registry=https://registry.npmjs.org/
|
|
417
|
+
//registry.npmjs.org/:_authToken=npm_YOURTOKEN
|
|
418
|
+
```
|
|
403
419
|
|
|
404
|
-
|
|
420
|
+
**Never run `npm login --auth-type=web`.** It overwrites the automation token with a 2FA-bound web session, and the next publish fails with `EOTP`.
|
|
421
|
+
- **`gh auth login`.** The pre-flight requires it, and step 6 creates the release with it. Step 7 logs `mcp-publisher` in with its token (`gh auth token`) unless `MCP_REGISTRY_TOKEN` is set; either way the token needs `read:org` access to YawLabs, which the registry checks for the `io.github.YawLabs/*` namespace. `mcp-publisher` is downloaded to `~/.local/bin` on first use; set `MCP_PUBLISHER` to use another copy.
|
|
422
|
+
- **`jq`, `curl` and `tar` on `PATH`.** Step 3 edits `server.json` with `jq`. Step 7 downloads `mcp-publisher` with `curl` and `tar` on first use, unless `MCP_PUBLISHER` points at an existing copy. It also polls npm with `curl`, and skips that wait with a warning if `curl` is missing.
|
|
423
|
+
- **Rights to push to `main` and create `v*` tags.** The repository rulesets require a pull request for `main` and restrict `v*` tag creation, only organization admins bypass them, and step 4 pushes both directly.
|
|
405
424
|
|
|
406
425
|
## License
|
|
407
426
|
|
package/bin/lemonsqueezy-mcp.mjs
CHANGED
|
@@ -85,9 +85,9 @@
|
|
|
85
85
|
*
|
|
86
86
|
* LEMONSQUEEZY_SINK_URL is operator-configured, so its host is parsed out and
|
|
87
87
|
* added to the grant when set rather than assumed. Child-process stays denied
|
|
88
|
-
* UNLESS LEMONSQUEEZY_API_KEY_COMMAND is configured -- that feature
|
|
89
|
-
*
|
|
90
|
-
* unconditionally.
|
|
88
|
+
* UNLESS LEMONSQUEEZY_API_KEY_COMMAND is configured -- that feature runs the
|
|
89
|
+
* key command as a child process (execFile, no shell), so the grant is tied to
|
|
90
|
+
* the feature instead of handed over unconditionally.
|
|
91
91
|
*
|
|
92
92
|
* Opt-in, not default, because a wrong grant does not fail loudly. oam denies a
|
|
93
93
|
* non-granted environment variable by making it ABSENT from process.env rather
|
|
@@ -96,15 +96,17 @@
|
|
|
96
96
|
* shipped bundle actually reads -- keep it in step with the bundle.
|
|
97
97
|
*
|
|
98
98
|
* MINIMUM OAM VERSION
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
99
|
+
* OAM_MIN, 0.15.2, is a floor: the oam this server was last verified on, not
|
|
100
|
+
* a claim that it is the newest oam. Raise it once a newer oam has been
|
|
101
|
+
* re-verified here (handshake and all 64 tools through this launcher). The
|
|
102
|
+
* server only runs on an oam at or above the floor; an older one is never
|
|
103
|
+
* served on. The floor is not cosmetic: before 0.9.0 `child_process.execFile`
|
|
104
|
+
* ran its arguments through a SHELL, `exec` accepted `timeout` and ignored it,
|
|
103
105
|
* `spawnSync` truncated at `maxBuffer` while reporting success, and
|
|
104
|
-
* `stdio: 'inherit'`/`'ignore'` both behaved as `'pipe'`. This server
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* by a shell.
|
|
106
|
+
* `stdio: 'inherit'`/`'ignore'` both behaved as `'pipe'`. This server starts a
|
|
107
|
+
* child process only when LEMONSQUEEZY_API_KEY_COMMAND is configured (execFile,
|
|
108
|
+
* deliberately without a shell), so the execFile bug was reachable on exactly
|
|
109
|
+
* that path -- the command's arguments were re-split by a shell anyway.
|
|
108
110
|
*
|
|
109
111
|
* SELECTION
|
|
110
112
|
* LEMONSQUEEZY_MCP_RUNTIME=auto newest usable oam, else Node (default)
|