@yawlabs/lemonsqueezy-mcp 0.10.12 → 0.11.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 CHANGED
@@ -2,6 +2,88 @@
2
2
 
3
3
  All notable changes to `@yawlabs/lemonsqueezy-mcp` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versioning follows [SEMVER.md](./SEMVER.md).
4
4
 
5
+ ## [0.11.0] -- 2026-08-07
6
+
7
+ ### Security
8
+
9
+ - **`redactSecrets` no longer degrades to exponential traversal on a shared-reference payload.** The cycle guard tracks the ancestor path (entries removed on the way back up) so a merely *shared* object is redacted normally instead of being falsely reported as `[CIRCULAR]` -- but an ancestor set alone is O(paths), not O(nodes). A "diamond chain" holding two references to the same child at every level has 2^depth paths over depth+1 objects: measured at 3.2 seconds for 23 objects, doubling per level, which at the 32-level cap would block the stdio server for the better part of an hour from inside the audit path. A memo of completed subtrees restores linear behaviour. Reuse is gated on the cached entry having been computed with at least as much depth budget as the current position, so a subtree truncated near the cap is never replayed into a shallower slot where real (possibly secret-bearing) input belongs.
10
+
11
+ ### Added
12
+
13
+ - **`validation_error` log/audit status.** Failure entries previously collapsed client mistakes into `exception`, the same bucket as upstream 5xx and genuine faults. The new `ToolInputError` is tagged `validation_error`, so a log scan at `LEMONSQUEEZY_LOG=error` can separate "an agent sent a malformed request" from "the server or upstream faulted". `guardrail_block` (operator policy refused it) is unchanged.
14
+ - **`preflight` hook on the tool contract.** An input-dependent guardrail that runs ahead of the rate limiters. Both refund tools now declare the cap check there, so a rejected over-cap refund no longer consumes the caller's destructive and `money`-class budgets -- previously a client looping on an over-cap amount could exhaust a `money:2/h` allowance on calls that never left the process.
15
+ - **Cross-store disclosure on `ls_list_stores`.** It has no `storeId` field and no parent ID to scope by, so `LEMONSQUEEZY_ALLOWED_STORE_IDS` does not gate it and it enumerates every store the API key can see. This was documented in the README but absent from the tool description an agent actually reads. `ls_list_affiliates` carried the note already.
16
+
17
+ ### Fixed
18
+
19
+ - **Empty-PATCH guards on the remaining three update tools.** `ls_update_customer`, `ls_update_subscription`, and `ls_update_license_key` sent `attributes: {}` upstream when called with only an ID; `ls_update_webhook` had rejected this locally since 0.10.10. All four now reject before the round-trip and throw `ToolInputError`.
20
+ - **`ls_update_subscription` and `ls_update_customer` descriptions now disclose their conditional-destructive behaviour.** A tool whose destructiveness depends on the input carries `destructiveHint: false`, because the MCP annotation is static and the verdict is per-call -- so an MCP client will not prompt, and the description is the only signal left. `ls_update_subscription` said nothing about pausing or plan switches; `ls_update_customer` mentioned auditing only to point at `ls_archive_customer`, never saying that setting `status: 'archived'` *here* is itself rate-limited and audited. A new invariant in `tools.test.ts` fails the build if any predicate-carrying tool omits the warning.
21
+ - **`Retry-After: " "` no longer means "retry immediately".** `Number("")` is `0`, not `NaN`, so a whitespace-only header fell through the numeric branch and produced a 0 ms wait instead of the 1 s default.
22
+ - **`include: ""` no longer emits a bare `?include=`.** The include schemas set `.max()` but no `.min()`, so an empty string is valid input and split to `[""]`.
23
+ - **Sink response size limit measures UTF-8 bytes, not UTF-16 code units.** `String.length` undercounts every multi-byte character, so a non-ASCII body could exceed the 10 MB limit while reporting a char count under it -- and the message still said "bytes".
24
+ - **A bare `{"error": "..."}` envelope is now read on the management API**, not just the License API, via the shared error handler.
25
+ - **A throwing `isDestructive` predicate can no longer escape as an unhandled rejection.** It is evaluated outside the main try block so it stays in scope for the audit path on the error branch; it now has its own guard that fails closed (treats the call as destructive) and logs the fault.
26
+
27
+ ### Changed
28
+
29
+ - **`apiRequest` and `licenseRequest` share one error path and one 2xx body reader.** Roughly 90 lines of near-identical parse/log/decorate logic collapsed to two helpers. `licenseRequest` still deliberately does not bust the API-key cache on 401, since it authenticates with the caller's license key rather than `LEMONSQUEEZY_API_KEY`.
30
+ - **Both generate-invoice tools share one query builder** (`buildInvoiceQuery`) instead of duplicating an eight-field `URLSearchParams` block.
31
+ - **Cross-store notes are generated from each module's `requiredFilters` array**, so the disclosure and the runtime gate cannot drift apart.
32
+ - **`src/index.ts` is free of top-level await.** The version fallback used `await import("node:module")`, which the CJS single-binary build cannot emit; it survived only because both builds define `__VERSION__` and esbuild constant-folded the branch away. A static `createRequire` import removes the dependency on that folding -- verified by bundling to CJS with the define absent.
33
+
34
+ ### Added
35
+
36
+ - **Opt-in [oam.js](https://oamjs.org) build tooling.** `npm run check:oam` type-checks via `oam check` (tsgo, TypeScript 7 native) -- 2878ms against 4406ms for `tsc --noEmit`, same clean result. `npm run build:binary:oam` builds the standalone binary via `oam compile` instead of the Node SEA path, writing to the same `bin/<platform>-<arch>/` location so the release staging script consumes either unchanged. Neither touches the published npm package.
37
+
38
+ **Node remains the default runtime.** oam runs the server unmodified (verified against 0.8.2: full handshake, all 64 tools, the audit-log resource, working `fetch`, guardrail errors identical to Node), but cold start measured 196ms for Node against 424ms for `oam run` -- and since an MCP client pays startup once per session, that is the cost that matters. oam is also not distributed on npm, so defaulting to it would break `npx` for anyone without it. `src/` stays runtime-agnostic (no `oam:` imports, tests on `node:test`) so the Node path remains a real fallback rather than a nominal one.
39
+
40
+ - `oam/` added to `.gitignore` -- any `oam` invocation writes a bytecode cache there.
41
+
42
+ ### Documentation
43
+
44
+ - `0` documented as a valid value for the refund cap and both rate limits (blocks everything; unset/empty means no limit).
45
+ - `LEMONSQUEEZY_LOG` row now lists what each failure `status` means.
46
+ - README's allowlist row no longer describes `ls_list_stores` as routing "by its own resource ID"; the two genuinely ungated list tools are called out separately from the ID-targeted ones.
47
+
48
+ ## [0.10.13] -- 2026-06-11
49
+
50
+ ### Added
51
+
52
+ - **Cross-platform single-binary pipeline** (Scoop + Homebrew) via `scripts/build-binary.mjs` and `scripts/stage-release-asset.mjs`.
53
+
54
+ ### Fixed
55
+
56
+ - **`npm test` runs every test file under POSIX sh**, not only nested ones -- an unquoted `**/*` glob skipped the top-level files.
57
+
58
+ ### Changed
59
+
60
+ - Node engine requirement raised to `>=22`; dev-dependency vulnerabilities cleared.
61
+
62
+ ## [0.10.12] -- 2026-06-02
63
+
64
+ ### Fixed
65
+
66
+ - **Release drift guard compares tag-object SHAs**, so resuming a partial release no longer false-aborts.
67
+ - **`SKIP_LINT=1` escape hatch** for environments where the lint runner itself is broken.
68
+ - **`release.sh` refuses to push if origin's tag drifted from local.**
69
+ - README "Add to Yaw MCP" badge points at the https forwarder.
70
+
71
+ ### Added
72
+
73
+ - End-to-end coverage for the refund-cap guard on both refund tools.
74
+
75
+ ## [0.10.11] -- 2026-05-28
76
+
77
+ ### Changed
78
+
79
+ - **MCP Registry publish folded into `release.sh`**; `release.yml` and the remaining non-release workflows removed. `release.sh` is now the only release path.
80
+
81
+ ### Fixed
82
+
83
+ - Confirmation prompt is tty-gated, so non-interactive runs no longer block.
84
+ - `server.json` syncs unconditionally rather than only inside the version-bump branch.
85
+ - Falls back to the `gh` auth token for MCP Registry authentication.
86
+
5
87
  ## [0.10.10] -- 2026-05-22
6
88
 
7
89
  ### Fixed
@@ -390,6 +472,10 @@ Hardening pass for unattended automation against live billing flows.
390
472
  Initial release. 59 tools covering all 17 LemonSqueezy API resources.
391
473
 
392
474
  [Unreleased]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.9...HEAD
475
+ [0.11.0]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.13...v0.11.0
476
+ [0.10.13]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.12...v0.10.13
477
+ [0.10.12]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.11...v0.10.12
478
+ [0.10.11]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.10...v0.10.11
393
479
  [0.10.10]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.9...v0.10.10
394
480
  [0.10.9]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.8...v0.10.9
395
481
  [0.10.8]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.7...v0.10.8
package/README.md CHANGED
@@ -229,12 +229,12 @@ All configuration is via environment variables. Only `LEMONSQUEEZY_API_KEY` (or
229
229
  | `LEMONSQUEEZY_API_KEY` | LemonSqueezy API token. |
230
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
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 (e.g. `ls_refund_order`, `ls_cancel_subscription`, `ls_archive_customer`, `ls_delete_webhook`, `ls_delete_discount`, `ls_update_license_key`, `ls_list_stores`) route by their own resource ID and are **not** gated by this allowlist. 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` | Rejects `ls_refund_order` and `ls_refund_subscription_invoice` calls above this amount. |
234
- | `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` | Positive integer. Max destructive tool calls per 60-second rolling window. 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"`. |
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
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. 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. |
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
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
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
240
 
@@ -316,6 +316,33 @@ npm run gen:containerfile # regenerate Containerfile
316
316
  npm run check:containerfile # CI runs this; non-zero exit means the two have drifted
317
317
  ```
318
318
 
319
+ ## Running on oam.js (optional)
320
+
321
+ [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.8.2: full MCP handshake, all 64 tools, the `lemonsqueezy://audit-log` resource, working `fetch`, and guardrail rejections with error text identical to Node.
322
+
323
+ ```jsonc
324
+ {
325
+ "mcpServers": {
326
+ "lemonsqueezy": {
327
+ "command": "oam",
328
+ "args": ["run", "/path/to/lemonsqueezy-mcp/dist/index.js"],
329
+ "env": { "LEMONSQUEEZY_API_KEY": "..." }
330
+ }
331
+ }
332
+ }
333
+ ```
334
+
335
+ Note the `--` separator if you pass arguments to the server rather than to oam: `oam run dist/index.js -- version`.
336
+
337
+ **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.
338
+
339
+ Two places oam *does* win for this repo, both opt-in and neither touching the npm package:
340
+
341
+ - **`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.
342
+ - **`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.
343
+
344
+ 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.
345
+
319
346
  ## Releasing
320
347
 
321
348
  Two paths from a clean checkout of `main`. Both produce the same artifact (npm publish with provenance + GitHub release).
package/dist/index.js CHANGED
@@ -6,7 +6,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
8
  var __commonJS = (cb, mod) => function __require() {
9
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
9
+ try {
10
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
11
+ } catch (e) {
12
+ throw mod = 0, e;
13
+ }
10
14
  };
11
15
  var __export = (target, all) => {
12
16
  for (var name in all)
@@ -6885,6 +6889,9 @@ var require_dist = __commonJS({
6885
6889
  }
6886
6890
  });
6887
6891
 
6892
+ // src/index.ts
6893
+ import "node:module";
6894
+
6888
6895
  // node_modules/zod/v3/external.js
6889
6896
  var external_exports = {};
6890
6897
  __export(external_exports, {
@@ -21104,6 +21111,12 @@ var GuardrailError = class extends Error {
21104
21111
  this.name = "GuardrailError";
21105
21112
  }
21106
21113
  };
21114
+ var ToolInputError = class extends Error {
21115
+ constructor(message) {
21116
+ super(message);
21117
+ this.name = "ToolInputError";
21118
+ }
21119
+ };
21107
21120
  var AUTHORITY_CLASSES = ["read", "pii", "mutate", "money", "recurring", "key", "webhook"];
21108
21121
  function isAuthorityClass(s) {
21109
21122
  return AUTHORITY_CLASSES.includes(s);
@@ -21288,7 +21301,14 @@ function getLogLevel() {
21288
21301
  if (v === "error") return "error";
21289
21302
  return "off";
21290
21303
  }
21291
- var ERROR_STATUS_TAGS = /* @__PURE__ */ new Set(["error", "exception", "guardrail_block", "timeout", "network_error"]);
21304
+ var ERROR_STATUS_TAGS = /* @__PURE__ */ new Set([
21305
+ "error",
21306
+ "exception",
21307
+ "guardrail_block",
21308
+ "validation_error",
21309
+ "timeout",
21310
+ "network_error"
21311
+ ]);
21292
21312
  function isErrorEntry(entry) {
21293
21313
  if (entry.error !== void 0) return true;
21294
21314
  if (typeof entry.status === "string") return ERROR_STATUS_TAGS.has(entry.status);
@@ -21370,6 +21390,7 @@ var JITTER_FRACTION = 0.25;
21370
21390
  function parseRetryAfterMs(header) {
21371
21391
  if (!header) return DEFAULT_RETRY_WAIT_MS;
21372
21392
  const trimmed = header.trim();
21393
+ if (trimmed === "") return DEFAULT_RETRY_WAIT_MS;
21373
21394
  const seconds = Number(trimmed);
21374
21395
  if (!Number.isNaN(seconds)) {
21375
21396
  return seconds >= 0 ? seconds * 1e3 : DEFAULT_RETRY_WAIT_MS;
@@ -21582,7 +21603,10 @@ function buildQuery(params) {
21582
21603
  if (!params) return "";
21583
21604
  const parts = [];
21584
21605
  if (params.include?.length) {
21585
- parts.push(`include=${encodeURIComponent(params.include.map((s) => s.trim()).join(","))}`);
21606
+ const segments = params.include.map((s) => s.trim()).filter(Boolean);
21607
+ if (segments.length > 0) {
21608
+ parts.push(`include=${encodeURIComponent(segments.join(","))}`);
21609
+ }
21586
21610
  }
21587
21611
  if (params.filter) {
21588
21612
  for (const [key, value] of Object.entries(params.filter)) {
@@ -21602,13 +21626,57 @@ function buildQuery(params) {
21602
21626
  function decorateError(error2, requestId) {
21603
21627
  return requestId ? `${error2} (request_id: ${requestId})` : error2;
21604
21628
  }
21629
+ async function handleErrorResponse(res, route, latency_ms, requestId) {
21630
+ const errorBody = await res.text();
21631
+ try {
21632
+ const parsed = JSON.parse(errorBody);
21633
+ const detail = parsed.errors?.[0]?.detail ?? parsed.error ?? errorBody;
21634
+ logEvent({
21635
+ event: "http_call",
21636
+ method: route.method,
21637
+ path: route.path,
21638
+ status: res.status,
21639
+ latency_ms,
21640
+ request_id: requestId,
21641
+ error: detail
21642
+ });
21643
+ return {
21644
+ ok: false,
21645
+ status: res.status,
21646
+ data: parsed,
21647
+ error: decorateError(detail, requestId),
21648
+ requestId
21649
+ };
21650
+ } catch {
21651
+ logEvent({
21652
+ event: "http_call",
21653
+ method: route.method,
21654
+ path: route.path,
21655
+ status: res.status,
21656
+ latency_ms,
21657
+ request_id: requestId,
21658
+ error: errorBody
21659
+ });
21660
+ return {
21661
+ ok: false,
21662
+ status: res.status,
21663
+ error: decorateError(errorBody, requestId),
21664
+ requestId
21665
+ };
21666
+ }
21667
+ }
21668
+ async function readJsonBody(res) {
21669
+ const bodyText = await res.text();
21670
+ return bodyText.trim() ? JSON.parse(bodyText) : void 0;
21671
+ }
21605
21672
  function formatTimeoutMessage(err, fallbackElapsedMs) {
21673
+ const plural = (n) => `${n} attempt${n === 1 ? "" : "s"}`;
21606
21674
  if (isRetryTimeoutError(err)) {
21607
21675
  const seconds2 = Math.max(1, Math.round(err.elapsedMs / 1e3));
21608
- return `Request timed out after ${seconds2}s (${err.attempts} attempts)`;
21676
+ return `Request timed out after ${seconds2}s (${plural(err.attempts)})`;
21609
21677
  }
21610
21678
  const seconds = Math.max(1, Math.round(fallbackElapsedMs / 1e3));
21611
- return `Request timed out after ${seconds}s (1 attempts)`;
21679
+ return `Request timed out after ${seconds}s (${plural(1)})`;
21612
21680
  }
21613
21681
  async function apiRequest(method, path, body) {
21614
21682
  const start = Date.now();
@@ -21644,43 +21712,7 @@ async function apiRequest(method, path, body) {
21644
21712
  if (res.status === 401 || res.status === 403) {
21645
21713
  invalidateApiKeyCache();
21646
21714
  }
21647
- const errorBody = await res.text();
21648
- try {
21649
- const parsed = JSON.parse(errorBody);
21650
- const detail = parsed.errors?.[0]?.detail ?? errorBody;
21651
- logEvent({
21652
- event: "http_call",
21653
- method,
21654
- path,
21655
- status: res.status,
21656
- latency_ms,
21657
- request_id: requestId,
21658
- error: detail
21659
- });
21660
- return {
21661
- ok: false,
21662
- status: res.status,
21663
- data: parsed,
21664
- error: decorateError(detail, requestId),
21665
- requestId
21666
- };
21667
- } catch {
21668
- logEvent({
21669
- event: "http_call",
21670
- method,
21671
- path,
21672
- status: res.status,
21673
- latency_ms,
21674
- request_id: requestId,
21675
- error: errorBody
21676
- });
21677
- return {
21678
- ok: false,
21679
- status: res.status,
21680
- error: decorateError(errorBody, requestId),
21681
- requestId
21682
- };
21683
- }
21715
+ return handleErrorResponse(res, { method, path }, latency_ms, requestId);
21684
21716
  }
21685
21717
  logEvent({
21686
21718
  event: "http_call",
@@ -21693,9 +21725,7 @@ async function apiRequest(method, path, body) {
21693
21725
  if (res.status === 204) {
21694
21726
  return { ok: true, status: res.status, requestId };
21695
21727
  }
21696
- const bodyText = await res.text();
21697
- const data = bodyText.trim() ? JSON.parse(bodyText) : void 0;
21698
- return { ok: true, status: res.status, data, requestId };
21728
+ return { ok: true, status: res.status, data: await readJsonBody(res), requestId };
21699
21729
  }
21700
21730
  async function licenseRequest(path, body) {
21701
21731
  const url = `${BASE_URL}${path}`;
@@ -21735,44 +21765,7 @@ async function licenseRequest(path, body) {
21735
21765
  const latency_ms = Date.now() - start;
21736
21766
  const requestId = res.headers.get("x-request-id") ?? void 0;
21737
21767
  if (!res.ok) {
21738
- const errorBody = await res.text();
21739
- try {
21740
- const parsed = JSON.parse(errorBody);
21741
- const p = parsed;
21742
- const detail = p.errors?.[0]?.detail ?? p.error ?? errorBody;
21743
- logEvent({
21744
- event: "http_call",
21745
- method: "POST",
21746
- path,
21747
- status: res.status,
21748
- latency_ms,
21749
- request_id: requestId,
21750
- error: detail
21751
- });
21752
- return {
21753
- ok: false,
21754
- status: res.status,
21755
- data: parsed,
21756
- error: decorateError(detail, requestId),
21757
- requestId
21758
- };
21759
- } catch {
21760
- logEvent({
21761
- event: "http_call",
21762
- method: "POST",
21763
- path,
21764
- status: res.status,
21765
- latency_ms,
21766
- request_id: requestId,
21767
- error: errorBody
21768
- });
21769
- return {
21770
- ok: false,
21771
- status: res.status,
21772
- error: decorateError(errorBody, requestId),
21773
- requestId
21774
- };
21775
- }
21768
+ return handleErrorResponse(res, { method: "POST", path }, latency_ms, requestId);
21776
21769
  }
21777
21770
  logEvent({
21778
21771
  event: "http_call",
@@ -21782,9 +21775,7 @@ async function licenseRequest(path, body) {
21782
21775
  latency_ms,
21783
21776
  request_id: requestId
21784
21777
  });
21785
- const bodyText = await res.text();
21786
- const data = bodyText.trim() ? JSON.parse(bodyText) : void 0;
21787
- return { ok: true, status: res.status, data, requestId };
21778
+ return { ok: true, status: res.status, data: await readJsonBody(res), requestId };
21788
21779
  }
21789
21780
  function getHandler(endpoint, idField) {
21790
21781
  return async (input) => {
@@ -21808,6 +21799,32 @@ function listHandler(endpoint, filterMap = {}) {
21808
21799
  };
21809
21800
  return Object.assign(handler, { filterMap });
21810
21801
  }
21802
+ var INVOICE_FIELD_MAP = {
21803
+ name: "name",
21804
+ address: "address",
21805
+ city: "city",
21806
+ state: "state",
21807
+ zipCode: "zip_code",
21808
+ country: "country",
21809
+ notes: "notes",
21810
+ locale: "locale"
21811
+ };
21812
+ function buildInvoiceQuery(input) {
21813
+ const params = new URLSearchParams();
21814
+ for (const [inputKey, apiKey] of Object.entries(INVOICE_FIELD_MAP)) {
21815
+ const value = input[inputKey];
21816
+ if (value !== void 0) params.set(apiKey, value);
21817
+ }
21818
+ const qs = params.toString();
21819
+ return qs ? `?${qs}` : "";
21820
+ }
21821
+ var CROSS_STORE_TRAILER = "Pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.";
21822
+ function crossStoreFilterNote(filters) {
21823
+ return `Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: ${filters.join(", ")}. Even with that set, ${CROSS_STORE_TRAILER}`;
21824
+ }
21825
+ function crossStoreUngatedNote(returns) {
21826
+ return `Cross-store note: LEMONSQUEEZY_ALLOWED_STORE_IDS does NOT gate this tool -- it has no storeId field and no parent ID filter to scope by, so it returns ${returns}. ${CROSS_STORE_TRAILER}`;
21827
+ }
21811
21828
  async function apiGet(path) {
21812
21829
  return apiRequest("GET", path);
21813
21830
  }
@@ -21843,7 +21860,9 @@ var affiliateTools = [
21843
21860
  {
21844
21861
  name: "ls_list_affiliates",
21845
21862
  authorityClass: "read",
21846
- description: "List all affiliates for the authenticated user's stores, optionally filtered by user email. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool can still return affiliates tied to non-allowed stores -- the endpoint has no parent ID filter to scope by. Pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
21863
+ description: `List all affiliates for the authenticated user's stores, optionally filtered by user email. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreUngatedNote(
21864
+ "affiliates tied to every store the API key can see, including non-allowed ones"
21865
+ )}`,
21847
21866
  annotations: {
21848
21867
  title: "List affiliates",
21849
21868
  readOnlyHint: true,
@@ -22039,7 +22058,7 @@ var customerTools = [
22039
22058
  {
22040
22059
  name: "ls_update_customer",
22041
22060
  authorityClass: "pii",
22042
- description: "Update an existing customer's name, email, city, region, country, or status. The only supported status value is 'archived' \u2014 use ls_archive_customer for the dedicated, audit-tagged path.",
22061
+ description: "Update an existing customer's name, email, city, region, country, or status. The only supported status value is 'archived'; setting it here is the same operation as ls_archive_customer and is treated as destructive (rate-limited and audited). Other field edits are not.",
22043
22062
  annotations: {
22044
22063
  title: "Update customer",
22045
22064
  readOnlyHint: false,
@@ -22073,6 +22092,11 @@ var customerTools = [
22073
22092
  if (input.region !== void 0) attributes.region = input.region;
22074
22093
  if (input.country !== void 0) attributes.country = input.country;
22075
22094
  if (input.status !== void 0) attributes.status = input.status;
22095
+ if (Object.keys(attributes).length === 0) {
22096
+ throw new ToolInputError(
22097
+ "ls_update_customer requires at least one of: name, email, city, region, country, status"
22098
+ );
22099
+ }
22076
22100
  return apiPatch(`/customers/${encodePath(input.customerId)}`, {
22077
22101
  data: {
22078
22102
  type: "customers",
@@ -22109,6 +22133,7 @@ var customerTools = [
22109
22133
  ];
22110
22134
 
22111
22135
  // src/tools/discount-redemptions.ts
22136
+ var LIST_DISCOUNT_REDEMPTIONS_FILTERS = ["discountId", "orderId"];
22112
22137
  var discountRedemptionTools = [
22113
22138
  {
22114
22139
  name: "ls_get_discount_redemption",
@@ -22130,7 +22155,7 @@ var discountRedemptionTools = [
22130
22155
  {
22131
22156
  name: "ls_list_discount_redemptions",
22132
22157
  authorityClass: "read",
22133
- description: "List all discount redemptions, optionally filtered by discount or order. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: discountId, orderId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
22158
+ description: `List all discount redemptions, optionally filtered by discount or order. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_DISCOUNT_REDEMPTIONS_FILTERS)}`,
22134
22159
  annotations: {
22135
22160
  title: "List discount redemptions",
22136
22161
  readOnlyHint: true,
@@ -22145,7 +22170,7 @@ var discountRedemptionTools = [
22145
22170
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
22146
22171
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
22147
22172
  }),
22148
- requiredFilters: ["discountId", "orderId"],
22173
+ requiredFilters: LIST_DISCOUNT_REDEMPTIONS_FILTERS,
22149
22174
  handler: listHandler("/discount-redemptions", { discountId: "discount_id", orderId: "order_id" })
22150
22175
  }
22151
22176
  ];
@@ -22268,6 +22293,7 @@ var discountTools = [
22268
22293
  ];
22269
22294
 
22270
22295
  // src/tools/files.ts
22296
+ var LIST_FILES_FILTERS = ["variantId"];
22271
22297
  var fileTools = [
22272
22298
  {
22273
22299
  name: "ls_get_file",
@@ -22289,7 +22315,7 @@ var fileTools = [
22289
22315
  {
22290
22316
  name: "ls_list_files",
22291
22317
  authorityClass: "read",
22292
- description: "List all files, optionally filtered by variant. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: variantId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
22318
+ description: `List all files, optionally filtered by variant. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_FILES_FILTERS)}`,
22293
22319
  annotations: {
22294
22320
  title: "List files",
22295
22321
  readOnlyHint: true,
@@ -22303,12 +22329,13 @@ var fileTools = [
22303
22329
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
22304
22330
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
22305
22331
  }),
22306
- requiredFilters: ["variantId"],
22332
+ requiredFilters: LIST_FILES_FILTERS,
22307
22333
  handler: listHandler("/files", { variantId: "variant_id" })
22308
22334
  }
22309
22335
  ];
22310
22336
 
22311
22337
  // src/tools/license-key-instances.ts
22338
+ var LIST_LICENSE_KEY_INSTANCES_FILTERS = ["licenseKeyId"];
22312
22339
  var licenseKeyInstanceTools = [
22313
22340
  {
22314
22341
  name: "ls_get_license_key_instance",
@@ -22330,7 +22357,7 @@ var licenseKeyInstanceTools = [
22330
22357
  {
22331
22358
  name: "ls_list_license_key_instances",
22332
22359
  authorityClass: "read",
22333
- description: "List all license key instances (activations), optionally filtered by license key. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: licenseKeyId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
22360
+ description: `List all license key instances (activations), optionally filtered by license key. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_LICENSE_KEY_INSTANCES_FILTERS)}`,
22334
22361
  annotations: {
22335
22362
  title: "List license key instances",
22336
22363
  readOnlyHint: true,
@@ -22344,7 +22371,7 @@ var licenseKeyInstanceTools = [
22344
22371
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
22345
22372
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
22346
22373
  }),
22347
- requiredFilters: ["licenseKeyId"],
22374
+ requiredFilters: LIST_LICENSE_KEY_INSTANCES_FILTERS,
22348
22375
  handler: listHandler("/license-key-instances", { licenseKeyId: "license_key_id" })
22349
22376
  }
22350
22377
  ];
@@ -22429,6 +22456,11 @@ var licenseKeyTools = [
22429
22456
  if (input.activationLimit !== void 0) attributes.activation_limit = input.activationLimit;
22430
22457
  if (input.disabled !== void 0) attributes.disabled = input.disabled;
22431
22458
  if (input.expiresAt !== void 0) attributes.expires_at = input.expiresAt;
22459
+ if (Object.keys(attributes).length === 0) {
22460
+ throw new ToolInputError(
22461
+ "ls_update_license_key requires at least one of: activationLimit, disabled, expiresAt"
22462
+ );
22463
+ }
22432
22464
  return apiPatch(`/license-keys/${encodePath(input.licenseKeyId)}`, {
22433
22465
  data: {
22434
22466
  type: "license-keys",
@@ -22510,6 +22542,7 @@ var licenseTools = [
22510
22542
  ];
22511
22543
 
22512
22544
  // src/tools/order-items.ts
22545
+ var LIST_ORDER_ITEMS_FILTERS = ["orderId", "productId", "variantId"];
22513
22546
  var orderItemTools = [
22514
22547
  {
22515
22548
  name: "ls_get_order_item",
@@ -22531,7 +22564,7 @@ var orderItemTools = [
22531
22564
  {
22532
22565
  name: "ls_list_order_items",
22533
22566
  authorityClass: "read",
22534
- description: "List all order items, optionally filtered by order or product. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: orderId, productId, variantId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
22567
+ description: `List all order items, optionally filtered by order or product. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_ORDER_ITEMS_FILTERS)}`,
22535
22568
  annotations: {
22536
22569
  title: "List order items",
22537
22570
  readOnlyHint: true,
@@ -22547,7 +22580,7 @@ var orderItemTools = [
22547
22580
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
22548
22581
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
22549
22582
  }),
22550
- requiredFilters: ["orderId", "productId", "variantId"],
22583
+ requiredFilters: LIST_ORDER_ITEMS_FILTERS,
22551
22584
  handler: listHandler("/order-items", { orderId: "order_id", productId: "product_id", variantId: "variant_id" })
22552
22585
  }
22553
22586
  ];
@@ -22618,17 +22651,7 @@ var orderTools = [
22618
22651
  locale: external_exports.string().max(1e4).optional().describe("Invoice language locale (e.g. 'en', 'fr', 'de')")
22619
22652
  }),
22620
22653
  handler: async (input) => {
22621
- const params = new URLSearchParams();
22622
- if (input.name !== void 0) params.set("name", input.name);
22623
- if (input.address !== void 0) params.set("address", input.address);
22624
- if (input.city !== void 0) params.set("city", input.city);
22625
- if (input.state !== void 0) params.set("state", input.state);
22626
- if (input.zipCode !== void 0) params.set("zip_code", input.zipCode);
22627
- if (input.country !== void 0) params.set("country", input.country);
22628
- if (input.notes !== void 0) params.set("notes", input.notes);
22629
- if (input.locale !== void 0) params.set("locale", input.locale);
22630
- const qs = params.toString();
22631
- return apiPost(`/orders/${encodePath(input.orderId)}/generate-invoice${qs ? `?${qs}` : ""}`);
22654
+ return apiPost(`/orders/${encodePath(input.orderId)}/generate-invoice${buildInvoiceQuery(input)}`);
22632
22655
  }
22633
22656
  },
22634
22657
  {
@@ -22646,6 +22669,12 @@ var orderTools = [
22646
22669
  orderId: lsIdSchema.describe("The order ID to refund"),
22647
22670
  amount: external_exports.number().int().min(1).describe("Refund amount in cents (e.g. 1000 = $10.00)")
22648
22671
  }),
22672
+ // Run the cap check ahead of the rate limiters (see `preflight` on
22673
+ // RegisterableTool) so a rejected over-cap refund does not consume the
22674
+ // caller's money-class and destructive budgets. The handler repeats the
22675
+ // check for direct callers that bypass the wrapper; checkRefundAmount is
22676
+ // pure, so running it twice is free.
22677
+ preflight: (input) => checkRefundAmount(input.amount),
22649
22678
  handler: async (input) => {
22650
22679
  checkRefundAmount(input.amount);
22651
22680
  return apiPost(`/orders/${encodePath(input.orderId)}/refund`, {
@@ -22656,6 +22685,7 @@ var orderTools = [
22656
22685
  ];
22657
22686
 
22658
22687
  // src/tools/prices.ts
22688
+ var LIST_PRICES_FILTERS = ["variantId"];
22659
22689
  var priceTools = [
22660
22690
  {
22661
22691
  name: "ls_get_price",
@@ -22677,7 +22707,7 @@ var priceTools = [
22677
22707
  {
22678
22708
  name: "ls_list_prices",
22679
22709
  authorityClass: "read",
22680
- description: "List all prices, optionally filtered by variant. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: variantId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
22710
+ description: `List all prices, optionally filtered by variant. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_PRICES_FILTERS)}`,
22681
22711
  annotations: {
22682
22712
  title: "List prices",
22683
22713
  readOnlyHint: true,
@@ -22691,7 +22721,7 @@ var priceTools = [
22691
22721
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
22692
22722
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
22693
22723
  }),
22694
- requiredFilters: ["variantId"],
22724
+ requiredFilters: LIST_PRICES_FILTERS,
22695
22725
  handler: listHandler("/prices", { variantId: "variant_id" })
22696
22726
  }
22697
22727
  ];
@@ -22847,10 +22877,11 @@ async function sinkRequest(config2, method, pathAndQuery) {
22847
22877
  }
22848
22878
  const text = readResult.text;
22849
22879
  if (!text.trim()) return { ok: true, data: {} };
22850
- if (text.length > MAX_BODY_SIZE_BYTES) {
22880
+ const byteLength = Buffer.byteLength(text, "utf8");
22881
+ if (byteLength > MAX_BODY_SIZE_BYTES) {
22851
22882
  return {
22852
22883
  ok: false,
22853
- error: `Sink response body too large: ${text.length} bytes exceeds ${MAX_BODY_SIZE_BYTES} byte limit`
22884
+ error: `Sink response body too large: ${byteLength} bytes exceeds ${MAX_BODY_SIZE_BYTES} byte limit`
22854
22885
  };
22855
22886
  }
22856
22887
  try {
@@ -22954,7 +22985,9 @@ var storeTools = [
22954
22985
  {
22955
22986
  name: "ls_list_stores",
22956
22987
  authorityClass: "read",
22957
- description: "List all stores for the authenticated user. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total.",
22988
+ description: `List all stores for the authenticated user. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreUngatedNote(
22989
+ "every store the API key can see, including stores outside the allowlist"
22990
+ )}`,
22958
22991
  annotations: {
22959
22992
  title: "List stores",
22960
22993
  readOnlyHint: true,
@@ -23042,18 +23075,8 @@ var subscriptionInvoiceTools = [
23042
23075
  locale: external_exports.string().max(1e4).optional().describe("Invoice language locale (e.g. 'en', 'fr', 'de')")
23043
23076
  }),
23044
23077
  handler: async (input) => {
23045
- const params = new URLSearchParams();
23046
- if (input.name !== void 0) params.set("name", input.name);
23047
- if (input.address !== void 0) params.set("address", input.address);
23048
- if (input.city !== void 0) params.set("city", input.city);
23049
- if (input.state !== void 0) params.set("state", input.state);
23050
- if (input.zipCode !== void 0) params.set("zip_code", input.zipCode);
23051
- if (input.country !== void 0) params.set("country", input.country);
23052
- if (input.notes !== void 0) params.set("notes", input.notes);
23053
- if (input.locale !== void 0) params.set("locale", input.locale);
23054
- const qs = params.toString();
23055
23078
  return apiPost(
23056
- `/subscription-invoices/${encodePath(input.subscriptionInvoiceId)}/generate-invoice${qs ? `?${qs}` : ""}`
23079
+ `/subscription-invoices/${encodePath(input.subscriptionInvoiceId)}/generate-invoice${buildInvoiceQuery(input)}`
23057
23080
  );
23058
23081
  }
23059
23082
  },
@@ -23072,6 +23095,9 @@ var subscriptionInvoiceTools = [
23072
23095
  subscriptionInvoiceId: lsIdSchema.describe("The subscription invoice ID to refund"),
23073
23096
  amount: external_exports.number().int().min(1).describe("Refund amount in cents (e.g. 1000 = $10.00)")
23074
23097
  }),
23098
+ // See the matching comment on ls_refund_order: the cap check runs ahead of
23099
+ // the rate limiters so a rejection costs the caller no budget.
23100
+ preflight: (input) => checkRefundAmount(input.amount),
23075
23101
  handler: async (input) => {
23076
23102
  checkRefundAmount(input.amount);
23077
23103
  return apiPost(`/subscription-invoices/${encodePath(input.subscriptionInvoiceId)}/refund`, {
@@ -23086,6 +23112,7 @@ var subscriptionInvoiceTools = [
23086
23112
  ];
23087
23113
 
23088
23114
  // src/tools/subscription-items.ts
23115
+ var LIST_SUBSCRIPTION_ITEMS_FILTERS = ["subscriptionId", "priceId"];
23089
23116
  var subscriptionItemTools = [
23090
23117
  {
23091
23118
  name: "ls_get_subscription_item",
@@ -23107,7 +23134,7 @@ var subscriptionItemTools = [
23107
23134
  {
23108
23135
  name: "ls_list_subscription_items",
23109
23136
  authorityClass: "read",
23110
- description: "List all subscription items, optionally filtered by subscription or price. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: subscriptionId, priceId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
23137
+ description: `List all subscription items, optionally filtered by subscription or price. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_SUBSCRIPTION_ITEMS_FILTERS)}`,
23111
23138
  annotations: {
23112
23139
  title: "List subscription items",
23113
23140
  readOnlyHint: true,
@@ -23122,7 +23149,7 @@ var subscriptionItemTools = [
23122
23149
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
23123
23150
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
23124
23151
  }),
23125
- requiredFilters: ["subscriptionId", "priceId"],
23152
+ requiredFilters: LIST_SUBSCRIPTION_ITEMS_FILTERS,
23126
23153
  handler: listHandler("/subscription-items", { subscriptionId: "subscription_id", priceId: "price_id" })
23127
23154
  },
23128
23155
  {
@@ -23229,7 +23256,7 @@ var subscriptionTools = [
23229
23256
  {
23230
23257
  name: "ls_update_subscription",
23231
23258
  authorityClass: "recurring",
23232
- description: "Update a subscription. Can change the variant (plan switch), pause/unpause, set billing anchor, or update invoice details. Use ls_cancel_subscription for cancellation.",
23259
+ description: "Update a subscription. Can change the variant (plan switch), pause/unpause, set billing anchor, or update invoice details. Pausing (`pause: 'void'` or `'free'`) or switching plan (`variantId`) is customer-impacting and is treated as destructive (rate-limited and audited); resuming and the billing-neutral edits are not. Use ls_cancel_subscription for cancellation.",
23233
23260
  annotations: {
23234
23261
  title: "Update subscription",
23235
23262
  readOnlyHint: false,
@@ -23269,6 +23296,11 @@ var subscriptionTools = [
23269
23296
  if (input.invoiceImmediately !== void 0) attributes.invoice_immediately = input.invoiceImmediately;
23270
23297
  if (input.disableProrations !== void 0) attributes.disable_prorations = input.disableProrations;
23271
23298
  if (input.trialEndsAt !== void 0) attributes.trial_ends_at = input.trialEndsAt;
23299
+ if (Object.keys(attributes).length === 0) {
23300
+ throw new ToolInputError(
23301
+ "ls_update_subscription requires at least one of: variantId, pause, cancelled, billingAnchor, invoiceImmediately, disableProrations, trialEndsAt"
23302
+ );
23303
+ }
23272
23304
  return apiPatch(`/subscriptions/${encodePath(input.subscriptionId)}`, {
23273
23305
  data: {
23274
23306
  type: "subscriptions",
@@ -23299,6 +23331,7 @@ var subscriptionTools = [
23299
23331
  ];
23300
23332
 
23301
23333
  // src/tools/usage-records.ts
23334
+ var LIST_USAGE_RECORDS_FILTERS = ["subscriptionItemId"];
23302
23335
  var usageRecordTools = [
23303
23336
  {
23304
23337
  name: "ls_get_usage_record",
@@ -23320,7 +23353,7 @@ var usageRecordTools = [
23320
23353
  {
23321
23354
  name: "ls_list_usage_records",
23322
23355
  authorityClass: "read",
23323
- description: "List all usage records, optionally filtered by subscription item. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: subscriptionItemId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
23356
+ description: `List all usage records, optionally filtered by subscription item. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_USAGE_RECORDS_FILTERS)}`,
23324
23357
  annotations: {
23325
23358
  title: "List usage records",
23326
23359
  readOnlyHint: true,
@@ -23334,7 +23367,7 @@ var usageRecordTools = [
23334
23367
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
23335
23368
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
23336
23369
  }),
23337
- requiredFilters: ["subscriptionItemId"],
23370
+ requiredFilters: LIST_USAGE_RECORDS_FILTERS,
23338
23371
  handler: listHandler("/usage-records", { subscriptionItemId: "subscription_item_id" })
23339
23372
  },
23340
23373
  {
@@ -23397,6 +23430,7 @@ var userTools = [
23397
23430
  ];
23398
23431
 
23399
23432
  // src/tools/variants.ts
23433
+ var LIST_VARIANTS_FILTERS = ["productId"];
23400
23434
  var variantTools = [
23401
23435
  {
23402
23436
  name: "ls_get_variant",
@@ -23418,7 +23452,7 @@ var variantTools = [
23418
23452
  {
23419
23453
  name: "ls_list_variants",
23420
23454
  authorityClass: "read",
23421
- description: "List all variants, optionally filtered by product. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: productId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
23455
+ description: `List all variants, optionally filtered by product. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_VARIANTS_FILTERS)}`,
23422
23456
  annotations: {
23423
23457
  title: "List variants",
23424
23458
  readOnlyHint: true,
@@ -23432,7 +23466,7 @@ var variantTools = [
23432
23466
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
23433
23467
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
23434
23468
  }),
23435
- requiredFilters: ["productId"],
23469
+ requiredFilters: LIST_VARIANTS_FILTERS,
23436
23470
  handler: listHandler("/variants", { productId: "product_id" })
23437
23471
  }
23438
23472
  ];
@@ -23539,7 +23573,7 @@ var webhookTools = [
23539
23573
  if (input.events !== void 0) attributes.events = input.events;
23540
23574
  if (input.secret !== void 0) attributes.secret = input.secret;
23541
23575
  if (Object.keys(attributes).length === 0) {
23542
- throw new Error("ls_update_webhook requires at least one of: url, events, secret");
23576
+ throw new ToolInputError("ls_update_webhook requires at least one of: url, events, secret");
23543
23577
  }
23544
23578
  return apiPatch(`/webhooks/${encodePath(input.webhookId)}`, {
23545
23579
  data: {
@@ -23610,30 +23644,32 @@ function looksLikeBearerToken(value) {
23610
23644
  if (value.length < 20) return false;
23611
23645
  return JWT_VALUE_RE.test(value);
23612
23646
  }
23613
- function redactInner(value, visited, depth) {
23647
+ function redactInner(value, ancestors, memo, depth) {
23614
23648
  if (depth > MAX_DEPTH) return CIRCULAR;
23615
23649
  if (typeof value === "string" && looksLikeBearerToken(value)) return REDACTED;
23616
23650
  if (value === null || typeof value !== "object") return value;
23617
- if (Array.isArray(value)) {
23618
- if (visited.has(value)) return CIRCULAR;
23619
- visited.add(value);
23620
- return value.map((item) => redactInner(item, visited, depth + 1));
23621
- }
23622
- if (!isPlainObject3(value)) return value;
23623
- if (visited.has(value)) return CIRCULAR;
23624
- visited.add(value);
23625
- const out = {};
23626
- for (const [key, val] of Object.entries(value)) {
23627
- if (SECRET_KEY_RE.test(key)) {
23628
- out[key] = REDACTED;
23629
- } else {
23630
- out[key] = redactInner(val, visited, depth + 1);
23651
+ const isArray = Array.isArray(value);
23652
+ if (!isArray && !isPlainObject3(value)) return value;
23653
+ if (ancestors.has(value)) return CIRCULAR;
23654
+ const cached3 = memo.get(value);
23655
+ if (cached3 !== void 0 && cached3.depth <= depth) return cached3.result;
23656
+ ancestors.add(value);
23657
+ let result;
23658
+ if (isArray) {
23659
+ result = value.map((item) => redactInner(item, ancestors, memo, depth + 1));
23660
+ } else {
23661
+ const out = {};
23662
+ for (const [key, val] of Object.entries(value)) {
23663
+ out[key] = SECRET_KEY_RE.test(key) ? REDACTED : redactInner(val, ancestors, memo, depth + 1);
23631
23664
  }
23665
+ result = out;
23632
23666
  }
23633
- return out;
23667
+ ancestors.delete(value);
23668
+ memo.set(value, { depth, result });
23669
+ return result;
23634
23670
  }
23635
23671
  function redactSecrets(input) {
23636
- return redactInner(input, /* @__PURE__ */ new WeakSet(), 0);
23672
+ return redactInner(input, /* @__PURE__ */ new WeakSet(), /* @__PURE__ */ new WeakMap(), 0);
23637
23673
  }
23638
23674
 
23639
23675
  // src/wrapper.ts
@@ -23655,10 +23691,23 @@ function createToolHandler(tool) {
23655
23691
  }
23656
23692
  const inputAsRecord = input;
23657
23693
  const toolAsRecord = tool;
23658
- const isDestructive = isDestructiveCall(toolAsRecord, inputAsRecord);
23694
+ let isDestructive;
23695
+ try {
23696
+ isDestructive = isDestructiveCall(toolAsRecord, inputAsRecord);
23697
+ } catch (predicateErr) {
23698
+ isDestructive = true;
23699
+ logEvent({
23700
+ event: "tool_call",
23701
+ tool: tool.name,
23702
+ status: "exception",
23703
+ latency_ms: 0,
23704
+ error: `isDestructive predicate for tool "${tool.name}" threw: ${predicateErr instanceof Error ? predicateErr.message : String(predicateErr)}. Treating the call as destructive.`
23705
+ });
23706
+ }
23659
23707
  const start = Date.now();
23660
23708
  try {
23661
23709
  checkClassAllowed(tool.authorityClass);
23710
+ tool.preflight?.(input);
23662
23711
  checkClassRateLimit(tool.authorityClass);
23663
23712
  if (isDestructive) checkDestructiveRateLimit();
23664
23713
  checkStoreScopedToolInput(toolAsRecord, inputAsRecord);
@@ -23704,7 +23753,10 @@ function createToolHandler(tool) {
23704
23753
  const errorEntry = {
23705
23754
  event: "tool_call",
23706
23755
  tool: tool.name,
23707
- status: err instanceof GuardrailError ? "guardrail_block" : "exception",
23756
+ // Three distinct buckets, because they mean three different things
23757
+ // to whoever is reading the log: operator policy refused the call,
23758
+ // the client sent a bad request, or something actually broke.
23759
+ status: err instanceof GuardrailError ? "guardrail_block" : err instanceof ToolInputError ? "validation_error" : "exception",
23708
23760
  latency_ms,
23709
23761
  error: message,
23710
23762
  audit: isDestructive ? true : void 0,
@@ -23737,7 +23789,7 @@ function readAuditLogResource(uri) {
23737
23789
  }
23738
23790
 
23739
23791
  // src/index.ts
23740
- var version2 = true ? "0.10.12" : (await null).createRequire(import.meta.url)("../package.json").version;
23792
+ var version2 = true ? "0.11.0" : createRequire(import.meta.url)("../package.json").version;
23741
23793
  var subcommand = process.argv[2];
23742
23794
  if (subcommand === "version" || subcommand === "--version") {
23743
23795
  console.log(version2);
@@ -23790,5 +23842,9 @@ server.resource(
23790
23842
  async (uri) => readAuditLogResource(uri)
23791
23843
  );
23792
23844
  var transport = new StdioServerTransport();
23793
- await server.connect(transport);
23845
+ server.connect(transport).catch((err) => {
23846
+ process.stderr.write(`lemonsqueezy-mcp: ${err instanceof Error ? err.message : String(err)}
23847
+ `);
23848
+ process.exit(1);
23849
+ });
23794
23850
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/lemonsqueezy-mcp",
3
- "version": "0.10.12",
3
+ "version": "0.11.0",
4
4
  "mcpName": "io.github.YawLabs/lemonsqueezy-mcp",
5
5
  "description": "LemonSqueezy MCP server for managing your store from AI assistants",
6
6
  "license": "MIT",
@@ -32,27 +32,29 @@
32
32
  "build": "tsc && node build.mjs",
33
33
  "dev": "tsc --watch",
34
34
  "start": "node dist/index.js",
35
- "test": "npm run build && node --test dist/**/*.test.js",
35
+ "test": "npm run build && node --test \"dist/**/*.test.js\"",
36
36
  "test:integration": "npm run build && node --test dist/integration/*.test.js",
37
37
  "lint": "biome check src/",
38
38
  "lint:fix": "biome check --write src/",
39
+ "check:oam": "oam check",
40
+ "build:binary:oam": "node scripts/build-binary.mjs --oam",
39
41
  "gen:containerfile": "node scripts/sync-containerfile.mjs",
40
42
  "check:containerfile": "node scripts/sync-containerfile.mjs --check",
41
43
  "prepublishOnly": "npm run build"
42
44
  },
43
- "dependencies": {},
44
45
  "overrides": {
45
46
  "hono": "^4.12.14"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@biomejs/biome": "^2.4.12",
49
50
  "@modelcontextprotocol/sdk": "^1.29.0",
50
- "@types/node": "^25.6.0",
51
- "esbuild": "^0.28.0",
52
- "typescript": "^6.0.3",
51
+ "@types/node": "^26.1.1",
52
+ "esbuild": "^0.28.1",
53
+ "postject": "^1.0.0-alpha.6",
54
+ "typescript": "^7.0.2",
53
55
  "zod": "^4.3.6"
54
56
  },
55
57
  "engines": {
56
- "node": ">=20"
58
+ "node": ">=22"
57
59
  }
58
60
  }